mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 20:18:06 -06:00
Read /ActualText from named property lists in edit text and export (#3301)
Fixes #3110 Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 5.5
parent
16f36810cc
commit
e2d4814089
@@ -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 <</ActualText (Hello World)>> 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::<Vec<_>>(), [expected], "{content}");
|
||||
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, expected, "{content}");
|
||||
let reading: Vec<String> = 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<String> = 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();
|
||||
|
||||
+15
-7
@@ -740,7 +740,8 @@ fn interpret(
|
||||
cache: &mut HashMap<Vec<u8>, (Rc<Metrics>, Option<String>)>,
|
||||
carry: &mut Carry,
|
||||
mut invoked: Option<&mut Vec<Invocation>>,
|
||||
logical_text: bool,
|
||||
// `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`.
|
||||
logical_text: Option<&Dict>,
|
||||
) -> Vec<Shown> {
|
||||
let mut out: Vec<Shown> = 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<Vec<TextLine>, 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::<Vec<_>>());
|
||||
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<Vec<TextLine>, 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::<Vec<_>>());
|
||||
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::<Vec<_>>());
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user