From 2ef2e2b82286acbf4c1ff72fcb7a82ef52fe7a54 Mon Sep 17 00:00:00 2001 From: Fatih Kunak Date: Mon, 9 Nov 2026 07:44:48 +0700 Subject: [PATCH] fix(fonts): a subset that gives the space no width can't encode one (#6131) (#6158) --- crates/edit/src/tests.rs | 110 ++++++++++++++++++++++++++++++++++ crates/fonts/src/pdf.rs | 6 +- crates/fonts/src/pdf/tests.rs | 70 ++++++++++++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 700117ac6f..436809c045 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -10868,6 +10868,116 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() { assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}"); } +/// #6131: a subset that writes width 0 for its unused space (pdfTeX does) has no space glyph. A word added to +/// the line takes a positioning gap for its spaces, not code 32 at width 0, which would run the words together. +#[test] +fn a_space_in_a_subset_that_gives_it_no_width_is_a_gap_not_code_32() { + for subtype in ["TrueType", "Type1"] { + // Codes 32..=98: the space and everything else 0 wide, but a and b (97, 98). + let widths: Vec = (32..=98).map(|c| if c == 97 || c == 98 { "500" } else { "0" }.to_string()).collect(); + let font = format!( + "<< /Type /Font /Subtype /{subtype} /BaseFont /ABCDEF+Sample /FirstChar 32 /LastChar 98 /Widths [{}] /Encoding /WinAnsiEncoding >>", + widths.join(" ") + ); + let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), + format!( + "<< /Length {} >> +stream +{content} +endstream", + content.len() + ), + font, + ]; + let mut out = b"%PDF-1.7 +" + .to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice( + format!( + "{} 0 obj +{o} +endobj +", + i + 1 + ) + .as_bytes(), + ); + } + let x = out.len(); + out.extend_from_slice( + format!( + "xref +0 {} +0000000000 65535 f +", + objs.len() + 1 + ) + .as_bytes(), + ); + for o in offs { + out.extend_from_slice( + format!( + "{o:010} 00000 n +" + ) + .as_bytes(), + ); + } + out.extend_from_slice( + format!( + "trailer +<< /Size {} /Root 1 0 R >> +startxref +{x} +%%EOF +", + objs.len() + 1 + ) + .as_bytes(), + ); + let mut doc = Document::open(Arc::new(out)).unwrap(); + assert_eq!(text::replace_block(&mut doc, 0, 0, "ab ba").unwrap().substituted, None, "{subtype}"); + // Every byte the text-showing operators draw (strings inside `TJ` arrays too) and every + // `TJ` adjustment, read from the saved content rather than matched as text. + let ops = pdfcraft_content::parse(&page_content_bytes(&reopen(&doc), 0)).ops; + let mut shown: Vec = Vec::new(); + let mut adjustments: Vec = Vec::new(); + // How many bytes had been drawn when each adjustment came: the gap must sit between the words. + let mut positions: Vec = Vec::new(); + for op in ops.iter().filter(|o| matches!(o.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"")) { + for operand in &op.operands { + match operand { + Object::String(s) => shown.extend_from_slice(&s.bytes), + Object::Array(items) => { + for item in items { + match item { + Object::String(s) => shown.extend_from_slice(&s.bytes), + number => { + if let Some(adjustment) = number.as_f64() { + adjustments.push(adjustment); + positions.push(shown.len()); + } + } + } + } + } + _ => {} + } + } + } + assert_eq!(shown, b"abba", "{subtype}: the words are drawn and no code 32 at width 0 between them"); + assert!(matches!(adjustments.as_slice(), [gap] if *gap < 0.0), "{subtype}: one adjustment moves the second word right: {adjustments:?}"); + assert_eq!(positions, [2], "{subtype}: the gap is between the words, after \"ab\""); + } +} + #[test] fn image_exclusive_scope_resets_path_on_scope_exit() { // An earlier scope has a path operation closed by Q (with 'h' closepath). diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0ef49c81a5..a0406754c8 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -951,7 +951,11 @@ impl Metrics { || (self.outlined_cids.is_empty() && self.explicit_default); } match code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) { - Some(w) => *w > 0.0 || code == 32, + // A full font keeps its space whatever `/Widths` says; a subset that gives its space no width has no + // space glyph (pdfTeX writes 0 for the codes it never uses), and a typed space drawn as code 32 would + // glue the words together (#6131): the edit sets it as a positioning gap instead. Only when code 32 + // is a space: another glyph a subset puts there keeps its code. + Some(w) => *w > 0.0 || (code == 32 && !(self.subset && self.text_of(code).is_some_and(|t| t.chars().all(char::is_whitespace)))), None => self.widths.is_empty() && !self.subset, } } diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index 59cd46d4be..67cdcd3a23 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -334,6 +334,76 @@ fn subset_fonts_only_encode_the_glyphs_they_have() { assert_eq!(m.encode("abc"), None); } +/// #6131: pdfTeX writes `/FirstChar` below 32 and `/Widths` of 0 for the codes it never uses, the space +/// among them: the subset has no space glyph, and the words are apart by `TJ` gaps. Writing a typed space as +/// code 32 would glue the words together at width 0, so the font can't encode one and the edit sets the +/// space as a gap instead (as for a subset whose widths stop short of 32). +#[test] +fn a_subset_that_gives_the_space_no_width_cannot_encode_one() { + // pdfTeX writes Type 1 fonts; the same rule holds for a TrueType subset. + for subtype in ["TrueType", "Type1"] { + a_zero_width_space_in_a_subset_of(subtype); + } +} + +fn a_zero_width_space_in_a_subset_of(subtype: &str) { + let metrics = |base: &str, space: i64| { + let mut doc = Document::new_empty(); + // Codes 32..=98: the space, then a and b (97, 98) with a width, everything else 0. + let widths: Vec = (32..=98) + .map(|code| { + Object::Int(match code { + 32 => space, + 97 | 98 => 500, + _ => 0, + }) + }) + .collect(); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name(subtype)), + ("BaseFont", Object::name(base)), + ("FirstChar", Object::Int(32)), + ("Widths", Object::Array(widths)), + ("Encoding", Object::name("WinAnsiEncoding")), + ], + ); + Metrics::from_dict(&doc, &f) + }; + let subset = metrics("ABCDEF+Sample", 0); + assert!(subset.subset); + assert_eq!(subset.encode("ab").as_deref(), Some(&b"ab"[..])); + assert_eq!(subset.encode("a b"), None, "no space glyph: a gap sets it"); + assert_eq!(subset.encode(" "), None); + assert_eq!(metrics("ABCDEF+Sample", 250).encode("a b").as_deref(), Some(&b"a b"[..]), "a subset that does draw the space"); + assert_eq!(metrics("Sample", 0).encode("a b").as_deref(), Some(&b"a b"[..]), "a full font keeps its space whatever the width says"); +} + +/// Only a *space* at code 32 is given up for a width of 0: a subset that puts another glyph there (a symbol +/// font, say) keeps encoding it. +#[test] +fn a_subset_keeps_a_zero_width_glyph_that_is_not_a_space_at_code_32() { + let mut doc = Document::new_empty(); + let widths: Vec = (32..=98).map(|code| Object::Int(if code == 97 { 500 } else { 0 })).collect(); + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(32), Object::name("Z")])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("ABCDEF+Sample")), + ("FirstChar", Object::Int(32)), + ("Widths", Object::Array(widths)), + ("Encoding", Object::Dict(enc)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert!(m.subset); + assert_eq!(m.text_of(32).as_deref(), Some("Z")); + assert_eq!(m.encode("Z").as_deref(), Some(&[32][..]), "code 32 is a glyph here, not a space"); +} + #[test] fn cmap_streams_decode_within_a_size_cap() { // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole.