fix(fonts): a subset that gives the space no width can't encode one (#6131) (#6158)

This commit is contained in:
Fatih Kunak authored and GitHub committed 2026-11-09 08:44:48 +08:00
1 parent 5ee4a85876
commit 2ef2e2b822
3 files changed
+185 -1

No files matched your search

+110
View File
@@ -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<String> = (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<u8> = Vec::new();
let mut adjustments: Vec<f64> = Vec::new();
// How many bytes had been drawn when each adjustment came: the gap must sit between the words.
let mut positions: Vec<usize> = 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).
+5 -1
View File
@@ -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,
}
}
+70
View File
@@ -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<Object> = (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<Object> = (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.