diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index c7e911fde2..537ed908d7 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -263,7 +263,9 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { 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 { diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index fcd681efc9..d4e5e88206 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -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 = 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 { let b = source.as_bytes(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 879ebc6c66..3c66aeea69 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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:?}"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 2672592178..c5010e0658 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -956,7 +956,11 @@ fn interpret( let Some((name, m)) = ts.font.clone() else { continue }; // Pieces: strings, and TJ number adjustments. let pieces: Vec = 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. diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 415382b232..c9a404b935 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -900,7 +900,10 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< tm = tlm; } let items: Vec = 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 diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 8bc5ec1418..d8c4b0948a 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -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"); +}