diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 41c749ed3c..0fa31ccbb1 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -188,7 +188,10 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { // Where each id's last glyph ended, in user space. let mut ends: HashMap = HashMap::new(); let mut gs = TextState::default(); - let mut saved: Vec = Vec::new(); + // With each saved state, the text and text line matrices: inside a text object `q`/`Q` + // save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's + // errata, issue 368), as the renderer does. + let mut saved: Vec<(TextState, Matrix, Matrix)> = Vec::new(); let mut unsaved = 0usize; let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { @@ -209,12 +212,12 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { b"EMC" => { stack.pop(); } - b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()), + b"q" if saved.len() < MAX_SAVED => saved.push((gs.clone(), tm, tlm)), b"q" => unsaved += 1, b"Q" if unsaved > 0 => unsaved -= 1, b"Q" => { if let Some(saved) = saved.pop() { - gs = saved; + (gs, tm, tlm) = saved; } } b"cm" => { diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index a6dcdba199..a70ffbf385 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1235,6 +1235,19 @@ fn headings_drawn_glyph_by_glyph_keep_their_words() { ); } +/// Inside a text object `q`/`Q` save and restore the text matrices (ISO 32000-2 §9.4.1, as +/// corrected by the PDF Association's errata, issue 368), as the renderer does: a word whose +/// glyphs continue after a `Q` stays one word. +#[test] +fn q_inside_a_text_object_restores_the_text_matrices() { + let doc = heading_page(&[ + "BT /F1 20 Tf 72 700 Td (Chap) Tj q 0 -100 Td Q (ter One) Tj ET".into(), + "BT /F1 20 Tf 72 650 Td (Chap) Tj q 1 0 0 1 300 300 Tm Q (ter Two) Tj ET".into(), + ]); + let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); + assert_eq!(titles, ["Chapter One", "Chapter Two"]); +} + #[test] fn an_untagged_document_has_no_headings() { let mut objs = headings_doc(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index aa624c355b..359155015f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -7536,3 +7536,24 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() { let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned(); assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}"); } + +/// Inside a text object `q`/`Q` save and restore the text and text line matrices (ISO 32000-2 +/// §9.4.1, as corrected by the PDF Association's errata, issue 368), as the renderer does: the +/// text after `Q` is found, and stays, where it is drawn. +#[test] +fn q_inside_a_text_object_restores_the_text_matrices() { + for (content, text, baseline) in [ + ("BT /F1 12 Tf 72 700 Td q 0 -100 Td Q (After) Tj ET", "After", [72.0, 700.0]), + ("BT /F1 12 Tf 72 700 Td q 1 0 0 1 300 300 Tm Q (After) Tj ET", "After", [72.0, 700.0]), + // `T*` moves from the restored line, not the one set inside q/Q. + ("BT /F1 12 Tf 14 TL 72 700 Td q 0 -100 Td Q T* (Next) Tj ET", "Next", [72.0, 686.0]), + ] { + let mut doc = text_page(content); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!((lines[0].text.as_str(), lines[0].baseline()), (text, baseline), "{content}"); + text::replace_line(&mut doc, 0, 0, "Edited").unwrap(); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!((lines[0].text.as_str(), lines[0].baseline()), ("Edited", baseline), "{content} after editing"); + } +} diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index e4248aedea..16b04b406f 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -425,7 +425,10 @@ fn positioned_tracking(shown: &mut [Shown]) { /// stream), an unbalanced `q`, the font and the colour still apply in the streams after it. pub(super) struct Carry { pub(super) ts: Ts, - pub(super) stack: Vec, + /// Each saved state with the text and text line matrices: inside a text object `q`/`Q` + /// save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's + /// errata, issue 368), as the renderer does. + pub(super) stack: Vec<(Ts, Matrix, Matrix)>, pub(super) marked: Vec>, /// For each open marked-content sequence, its optional content group when it is `/OC`. pub(super) layers: Vec>>, @@ -761,7 +764,7 @@ pub(super) fn interpret( let mut out: Vec = Vec::new(); let mut ts = carry.ts.clone(); let mut at_bt = (0usize, ts.clone()); - let mut stack: Vec = std::mem::take(&mut carry.stack); + let mut stack: Vec<(Ts, Matrix, Matrix)> = std::mem::take(&mut carry.stack); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let mut bt = 0usize; let mut marked = @@ -776,10 +779,10 @@ pub(super) fn interpret( b"EMC" => { marked.end(i, &mut out); } - b"q" => stack.push(ts.clone()), + b"q" => stack.push((ts.clone(), tm, tlm)), b"Q" => { - if let Some(s) = stack.pop() { - ts = s; + if let Some(saved) = stack.pop() { + (ts, tm, tlm) = saved; } } b"cm" => {