From e2d4814089c97d1ee55e6cd5d935e7bb70734a6d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:07:39 +0200 Subject: [PATCH] Read /ActualText from named property lists in edit text and export (#3301) Fixes #3110 Co-authored-by: Claude Haiku 5.5 --- crates/edit/src/tests.rs | 49 ++++++++++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 22 ++++++++++++------ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 374894a53..d8a8374af 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3436,6 +3436,55 @@ fn latin_ligature_actual_text_stays_on_its_line() { } } +/// #3110: `/ActualText` may come from a named property list (`/Span /MC0 BDC`) looked up in the +/// page's or the form's `/Properties`, as well as from an inline one. +#[test] +fn actual_text_from_named_property_list() { + let properties = "/Properties << /MC0 << /ActualText (Hello World) >> /MC1 << /Lang (en) >> >>"; + let page_with = |content: &str| { + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + &format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> {properties} >> /Contents 4 0 R >>"), + &stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]) + }; + for (content, expected) in [ + ("/Span /MC0 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"), + ("/Span <> BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"), + ("/Span /MC1 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"), + ("/Span /Missing BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"), + ] { + let doc = page_with(content); + for doc in [&doc, &reopen(&doc)] { + let lines = text::text_lines(doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), [expected], "{content}"); + assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, expected, "{content}"); + let reading: Vec = text::reading_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(reading, [expected], "{content}"); + } + } + + // A form's named list is looked up in the form's own /Properties, not the page's. + let fm = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /Properties << /MC0 << /ActualText (Form text) >> >> >>", + "/Span /MC0 BDC BT /F1 24 Tf 72 600 Td (Glyphs) Tj ET EMC", + ); + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + &format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> {properties} >> /Contents 4 0 R >>" + ), + &stream("", "/Fm1 Do"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + &fm, + ]); + let reading: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(reading, ["Form text"]); +} + #[test] fn actual_text_and_visual_rtl_glyphs_keep_their_own_reading_order() { let hex: String = "שלום".encode_utf16().map(|u| format!("{u:04X}")).collect(); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 45bb93919..32d63d5c8 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -740,7 +740,8 @@ fn interpret( cache: &mut HashMap, (Rc, Option)>, carry: &mut Carry, mut invoked: Option<&mut Vec>, - logical_text: bool, + // `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`. + logical_text: Option<&Dict>, ) -> Vec { let mut out: Vec = Vec::new(); let mut ts = carry.ts.clone(); @@ -757,11 +758,16 @@ fn interpret( // artifacts and inner spans, which inherit their enclosing region. let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); marked.push(if region { Some(i) } else { marked.last().copied().flatten() }); - let logical = op.operands.get(1).and_then(Object::as_dict).and_then(|d| d.get(b"ActualText")).and_then(|s| match s { + // The property list is inline or named in the resources' /Properties (§14.6.2). + let list = logical_text.and_then(|properties| match op.operands.get(1) { + Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)), + other => other.and_then(Object::as_dict).cloned(), + }); + let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s { Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()), _ => None, }); - actual.push(if logical_text { logical.map(|s| (out.len(), i, s)) } else { None }); + actual.push(logical.map(|s| (out.len(), i, s))); } b"EMC" => { marked.pop(); @@ -1018,7 +1024,8 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro // joined, so an operator whose operands end one piece still has them. let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true); + let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default(); + let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, Some(&properties)); let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended for mut s in shown { // A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a @@ -1261,7 +1268,8 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let mut cache = HashMap::new(); let mut invoked = Vec::new(); - let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true); + let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default(); + let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), Some(&properties)); let mut last = None; let mut last_stream = None; let mut forms = invoked.into_iter().peekable(); @@ -1899,7 +1907,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), let streams = content_streams(doc, &p.dict); let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false); + let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None); let mut patches = HashMap::new(); for s in shown.into_iter().filter(|s| drops.contains(&s.op)) { if s.state.render_mode >= 4 { @@ -2556,7 +2564,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false); + let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None); let mut patches = HashMap::new(); // Keep full precision: a tiny rounded text-space change can be magnified by a CTM, // and restoring rise/spacing must not alter following unselected text.