Merge PR #3571: fix: read the last array of a surplus-operand TJ, not the first (#3569)

This commit is contained in:
aiongg
2026-10-22 01:45:17 +00:00
6 changed files with 43 additions and 3 deletions
+3 -1
View File
@@ -263,7 +263,9 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
tm = tlm;
}
let pieces: Vec<&Object> = match op.op.as_slice() {
b"TJ" => op.operands.first().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(),
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still takes the
// one immediately before it, as the renderer and every other reader here do (#3569).
b"TJ" => op.operands.last().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(),
_ => op.operands.last().into_iter().collect(),
};
for piece in pieces {
+10
View File
@@ -926,6 +926,16 @@ fn an_untagged_document_has_no_headings() {
assert!(headings(&pdf(&objs, "")).is_empty());
}
/// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still
/// draws only the one immediately before it, as the renderer and every other reader agree (#3569)
/// -- not the first, which is never drawn at all.
#[test]
fn a_heading_with_a_tj_surplus_array_reads_the_one_the_page_draws() {
let doc = heading_page(&["BT /F1 20 Tf 72 700 Td [(FIRSTARR)] [(LASTARR)] TJ ET".into()]);
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
assert_eq!(titles, ["LASTARR"]);
}
/// The text of every `"…"` literal with a space in it, outside comments and byte strings.
fn sentences(source: &str) -> Vec<String> {
let b = source.as_bytes();
+11
View File
@@ -5218,3 +5218,14 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() {
assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}");
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched");
}
/// A `TJ` with a surplus array operand (`[(FIRSTARR)] [(LASTARR)] TJ`) is malformed, but the
/// operator still takes the one immediately before it, as the renderer, MuPDF and pdftotext do
/// (#3569) -- not the first, which this reader took before, showing a word the page never draws.
#[test]
fn a_tj_with_a_surplus_array_reads_the_last_one() {
let doc = text_page("BT /F1 24 Tf 20 100 Td [(FIRSTARR)] [(LASTARR)] TJ ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "LASTARR", "the operand immediately before TJ, as the page draws: {lines:?}");
}
+5 -1
View File
@@ -956,7 +956,11 @@ fn interpret(
let Some((name, m)) = ts.font.clone() else { continue };
// Pieces: strings, and TJ number adjustments.
let pieces: Vec<Object> = match op.op.as_slice() {
b"TJ" => op.operands.first().and_then(Object::as_array).cloned().unwrap_or_default(),
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still
// takes the one immediately before it, as the renderer and every other
// reader here do (#3569) -- not the first, which a reader counting operands
// from the front of a surplus list would otherwise take.
b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(),
_ => op.operands.last().cloned().into_iter().collect(),
};
// Tc is already included in the pen advance; TJ may encode tracking instead.
+4 -1
View File
@@ -900,7 +900,10 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
tm = tlm;
}
let items: Vec<Object> = match o {
b"TJ" => op.operands.first().and_then(Object::as_array).cloned().unwrap_or_default(),
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still
// takes the one immediately before it, as the renderer and every other
// reader here do (#3569), so a redaction search matches what is drawn.
b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(),
_ => op.operands.last().cloned().into_iter().collect(),
};
// Vertical-writing text is laid out horizontally by `show`, so its glyphs are
+10
View File
@@ -2186,3 +2186,13 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() {
}
mod property_retirement;
/// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still
/// draws only the one immediately before it (LASTARR, 7 glyphs, 35 pt wide from x = 10) -- not
/// the first (FIRSTARR, 8 glyphs), which is never drawn at all, as the renderer agrees (#3569).
#[test]
fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() {
let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td [(FIRSTARR)] [(LASTARR)] TJ ET", "", vec![]);
assert_eq!(under(&mut doc, 0, &[[45.0, 95.0, 50.0, 110.0]]), 0, "nothing is drawn past LASTARR's 35pt width");
assert_eq!(under(&mut doc, 0, &[[10.0, 95.0, 45.0, 110.0]]), 7, "LASTARR's 7 glyphs, the one the page draws");
}