From e7a15783f3ea51ddf502fe03fa4be8bb3eedcfa6 Mon Sep 17 00:00:00 2001 From: Books Date: Sat, 10 Oct 2026 09:22:07 +1100 Subject: [PATCH] Export to Word/HTML/RTF: read text and images drawn by form XObjects (fixes #314) (#406) * Export to Word/HTML/RTF: read text and images drawn by form XObjects (fixes #314) The office export built its paragraphs from text_blocks and its images from page_images. Both read only the page's own content streams (they back Edit text and Edit image, which rewrite those streams), so a page drawn through `/Fm1 Do`, as many print-to-PDF tools emit, exported as an empty document. Add read-only reading_blocks and reading_images to pdfcraft-edit. They follow Do into form XObjects (nested ones too), apply each form's /Matrix, use its own /Resources (or the caller's when it has none), and start it from the graphics state at the Do. Depth is capped at MAX_FORM_DEPTH and a form already being read is skipped, so self-referencing and mutually recursive forms are read once. export_pages uses them; editing is unchanged and still offers only the page's own text and images. * Export: a page's walk through form XObjects has a total visit budget, so forms that fan out can't hang it * Export: keep the image walk within clippy's argument limit --------- Co-authored-by: Brandon Thomas --- crates/edit/src/images.rs | 74 +++++++++++++ crates/edit/src/lib.rs | 4 +- crates/edit/src/tests.rs | 145 ++++++++++++++++++++++++++ crates/edit/src/text.rs | 196 ++++++++++++++++++++++++++++++++++- crates/engine/src/compare.rs | 14 +-- crates/engine/src/tests.rs | 28 +++++ 6 files changed, 449 insertions(+), 12 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 7f3a2a69..d9ba9c96 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -142,6 +142,80 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr Ok(out) } +/// The images page `page` (0-based) shows, in drawing order, including those drawn inside form +/// XObjects (Export to Word, HTML and RTF). Read-only: indexes into this list are not +/// [`page_images`] indexes, so they can't be passed to [`change_image`]. +pub fn reading_images(doc: &Document, page: usize) -> Result, EditError> { + /// The CTM and `q` stack carry from one page stream to the next (§7.8.2); a form starts + /// its own. + /// The forms being read (cycle guard) and how many were entered (the page's budget). + struct Forms { + path: Vec, + visits: usize, + } + struct State { + ctm: Matrix, + stack: Vec, + } + fn walk(doc: &Document, data: &[u8], resources: &Dict, st: &mut State, stream: usize, forms: &mut Forms, out: &mut Vec) { + let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let (ctm, stack) = (&mut st.ctm, &mut st.stack); + for (i, op) in parse(data).ops.iter().enumerate() { + match op.op.as_slice() { + b"q" => stack.push(*ctm), + b"Q" => *ctm = stack.pop().unwrap_or(*ctm), + b"cm" => { + if let Some(m) = op.nums::<6>() { + *ctm = Matrix(m).then(ctm); + } + } + b"Do" => { + let Some(name) = op.name(0) else { continue }; + if let Some(form) = crate::text::form_call(doc, resources, name, *ctm, &forms.path, &mut forms.visits) { + if let Some(r) = form.obj { + forms.path.push(r); + } + let mut inner = State { ctm: form.ctm, stack: Vec::new() }; + walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out); + if form.obj.is_some() { + forms.path.pop(); + } + continue; + } + let Some(r) = xo.get(name).and_then(Object::as_ref) else { continue }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + if s.dict.name(b"Subtype") != Some(b"Image") { + continue; + } + out.push(PageImage { + rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]), + matrix: ctm.0, + name: String::from_utf8_lossy(name).into_owned(), + object: Some(r), + is_form: false, + width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32, + height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32, + stream, + op: i, + draw_matrix: ctm.0, + }); + } + _ => {} + } + } + } + let p = page_of(doc, page)?; + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut out = Vec::new(); + let mut st = State { ctm: Matrix::IDENTITY, stack: Vec::new() }; + let mut forms = Forms { path: Vec::new(), visits: 0 }; + for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() { + walk(doc, &data, &res, &mut st, si, &mut forms, &mut out); + } + Ok(out) +} + /// What to do with an image. #[derive(Clone, Debug, PartialEq)] pub enum ImageChange { diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index dfdec9ed..532b3248 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -614,9 +614,9 @@ pub fn marks_present(doc: &Document) -> Vec { mod flatten; pub use flatten::{flatten, flatten_fill_sign}; pub mod images; -pub use images::{ImageChange, PageImage, change_image, page_images, rect_to_rect, turn_about_centre}; +pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre}; pub mod text; -pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, replace_block, replace_line, rewrite_block, text_blocks, text_lines}; +pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, text_lines}; pub mod added; pub use added::{Added, AddedImage, AddedText, Align, Content, Family, add_content, delete_content, first_undrawable, list_added, update_content}; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index c324aad6..4378eb25 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1429,3 +1429,148 @@ fn fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0() assert!(summaries(&doc).iter().any(|s| s.intent.as_deref() == Some("FreeTextTypeWriter")), "the hidden typewriter stays"); assert_eq!(summaries(&doc).iter().filter(|s| s.intent.as_deref() == Some("FreeTextTypeWriter")).count(), 1); } + +/// A one-page document from its objects (object 1 is the catalog). +fn build(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn stream(dict: &str, data: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + +/// #314: text and images drawn by (nested) form XObjects are read for export, placed through +/// every form matrix and set in each form's own fonts; editing still sees only the page's text. +#[test] +fn reading_follows_form_xobjects() { + let page = stream("", "q 1 0 0 1 10 0 cm /Fm1 Do Q BT /F1 12 Tf 72 100 Td (Page text) Tj ET"); + let fm1 = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 600 800] /Matrix [1 0 0 1 0 50] /Resources << /Font << /F1 7 0 R >> /XObject << /Fm2 8 0 R >> >>", + "BT /F1 18 Tf 72 700 Td (Hello from a test invoice) Tj ET /Fm2 Do", + ); + let fm2 = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 400] /Matrix [2 0 0 2 0 0] /Resources << /Font << /F2 9 0 R >> /XObject << /Im1 10 0 R >> >>", + "BT /F2 10 Tf 50 100 Td (Nested) Tj ET q 20 0 0 10 5 5 cm /Im1 Do Q", + ); + let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"); + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> >> /Contents 4 0 R >>", + &page, + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>", + &fm1, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + &fm2, + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>", + &image, + ]); + let close = |a: f64, b: f64| (a - b).abs() < 0.01; + + let blocks = text::reading_blocks(&doc, 0).unwrap(); + let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect(); + assert_eq!(texts, ["Hello from a test invoice", "Nested", "Page text"]); + let (hello, nested, own) = (&blocks[0], &blocks[1], &blocks[2]); + // Hello: (72, 700) moved by the form's (0, 50) and the page's (10, 0). + assert_eq!(hello.base_font, "Helvetica", "the form's own /F1, not the page's"); + assert!(close(hello.rect[0], 82.0) && close(hello.size, 18.0), "{hello:?}"); + // Nested: (50, 100) doubled by Fm2, then Fm1 and the page: (110, 250), 20 pt. + assert_eq!(nested.base_font, "Times-Bold"); + assert!(nested.bold); + assert!(close(nested.rect[0], 110.0) && close(nested.size, 20.0), "{nested:?}"); + assert_eq!(own.base_font, "Courier"); + + let images = images::reading_images(&doc, 0).unwrap(); + assert_eq!(images.len(), 1, "{images:?}"); + let r = images[0].rect; + assert!(close(r[0], 20.0) && close(r[1], 60.0) && close(r[2], 60.0) && close(r[3], 80.0), "{r:?}"); + assert_eq!((images[0].width, images[0].height), (1, 1)); + + // Editing works on the page's own streams only, unchanged. + let editable: Vec = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(editable, ["Page text"]); + // The page draws one figure of its own (Fm1, edited as a whole); the image inside Fm2 is not + // a page image. + let own = images::page_images(&doc, 0).unwrap(); + assert_eq!(own.len(), 1, "{own:?}"); + assert!(own[0].is_form && own[0].object == Some(pdfcraft_cos::ObjRef::new(6, 0)), "{own:?}"); +} + +/// A chain of forms, each drawing the next twenty times, twelve deep: within the depth cap but +/// 20^11 visits without a budget. Reading stops at the page's visit budget instead of hanging. +#[test] +fn form_xobjects_that_fan_out_stop_at_the_visit_budget() { + let font = "/Font << /F1 5 0 R >>"; + let levels = 12; + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F1 5 0 R >> /XObject << /N 6 0 R >> >> /Contents 4 0 R >>" + .to_string(), + stream("", "/N Do"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + ]; + for level in 0..levels { + let next = 7 + level; + objs.push(if level + 1 == levels { + stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} >>"), "BT /F1 10 Tf 10 10 Td (leaf) Tj ET") + } else { + stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /N {next} 0 R >> >>"), &"/N Do ".repeat(20)) + }); + } + let refs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let doc = build(&refs); + let start = std::time::Instant::now(); + let _ = text::reading_blocks(&doc, 0).unwrap(); + assert!(images::reading_images(&doc, 0).unwrap().is_empty()); + assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed()); +} + +/// Forms that draw themselves or each other, have no resources, a malformed matrix, or are +/// missing are read once (or skipped) without panicking or looping. +#[test] +fn hostile_form_xobjects_are_read_once() { + let page = stream("", "/Loop Do /A Do /NoRes Do /Bad Do /Missing Do /F1 Do"); + let font = "/Font << /F1 5 0 R >>"; + let lp = stream( + &format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /Loop 6 0 R >> >>"), + "/Loop Do BT /F1 10 Tf 10 10 Td (once) Tj ET", + ); + let a = stream( + &format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /B 8 0 R >> >>"), + "/B Do BT /F1 10 Tf 10 100 Td (from a) Tj ET", + ); + let b = stream( + &format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /A 7 0 R >> >>"), + "/A Do BT /F1 10 Tf 10 200 Td (from b) Tj ET", + ); + let nores = stream("/Subtype /Form /BBox [0 0 9 9]", "BT /F1 10 Tf 10 300 Td (inherited) Tj ET"); + let bad = stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Matrix [1 0 0] /Resources << {font} >>"), "BT /F1 10 Tf 10 400 Td (bad matrix) Tj ET"); + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F1 5 0 R >> /XObject << /Loop 6 0 R /A 7 0 R /NoRes 9 0 R /Bad 10 0 R >> >> /Contents 4 0 R >>", + &page, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + &lp, + &a, + &b, + &nores, + &bad, + ]); + let texts: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["once", "from b", "from a", "inherited", "bad matrix"]); + assert!(images::reading_images(&doc, 0).unwrap().is_empty()); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 449af022..3168db63 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use std::rc::Rc; -use pdfcraft_content::{Matrix, Op, Pieces, serialize_ops}; +use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; use pdfcraft_fonts::pdf::Metrics; use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from}; @@ -295,7 +295,17 @@ impl Carry { } } -fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap, Rc>, carry: &mut Carry) -> Vec { +/// A `Do` met while interpreting: its operator index, XObject name and the state there. +type Invocation = (usize, Vec, Ts); + +fn interpret( + doc: &Document, + ops: &[Op], + fonts_res: &Dict, + cache: &mut HashMap, Rc>, + carry: &mut Carry, + mut invoked: Option<&mut Vec>, +) -> Vec { let mut out = Vec::new(); let mut ts = carry.ts.clone(); let mut at_bt = (0usize, ts.clone()); @@ -346,6 +356,11 @@ fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap ts.scale = op.num(0).unwrap_or(100.0) / 100.0, b"TL" => ts.leading = op.num(0).unwrap_or(0.0), b"Ts" => ts.rise = op.num(0).unwrap_or(0.0), + b"Do" => { + if let (Some(list), Some(name)) = (invoked.as_deref_mut(), op.name(0)) { + list.push((i, name.to_vec(), ts.clone())); + } + } b"Td" | b"TD" => { if let Some([x, y]) = op.nums::<2>() { if op.is("TD") { @@ -463,7 +478,7 @@ 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); + let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None); let mut last: Option<(usize, usize, f64, f64, f64)> = None; // (piece, bt, baseline, end_x, size) for s in shown { // A line belongs to the piece its first operator starts in (lines in different pieces @@ -523,6 +538,173 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro Ok(lines) } +/// The line `s` continues, or a new one: `last` is the previous run of the same stream +/// (`bt`, baseline, end x, size). +fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64)>, s: Shown, stream: usize) { + let joins = last.is_some_and(|(bt, base, end, size)| { + bt == s.bt + && lines.last().is_some_and(|l| l.font.as_bytes() == s.font.as_slice()) + && lines + .last() + .is_some_and(|l| (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color) + && (s.baseline - base).abs() < size * 0.3 + && s.start_x > end - size + && s.start_x - end < size * 3.0 + }); + if joins && let Some(l) = lines.last_mut() { + let gap = s.start_x - last.map_or(s.start_x, |x| x.2); + if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { + l.text.push(' '); + } + l.text.push_str(&s.text); + l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.ops.push(s.op); + l.decodable &= s.decodable; + l.color = fill_color(&s.state.fill); + } else { + lines.push(TextLine { + text: s.text.clone(), + rect: s.rect, + font: String::from_utf8_lossy(&s.font).into_owned(), + base_font: s.base_font.clone(), + size: s.size, + bold: s.bold, + italic: s.italic, + color: fill_color(&s.state.fill), + decodable: s.decodable, + stream, + ops: vec![s.op], + origin: Origin { + tm: s.tm.0, + tlm: s.tlm.0, + k: (s.tm.then(&s.state.ctm).0[2].powi(2) + s.tm.then(&s.state.ctm).0[3].powi(2)).sqrt(), + ctm: s.state.ctm.0, + baseline: s.baseline, + x: s.start_x, + state: TextState::of(&s.state), + bt_op: s.bt_op, + bt_state: TextState::of(&s.bt_state), + }, + }); + } + *last = Some((s.bt, s.baseline, s.end_x, s.size.max(1.0))); +} + +/// How deep form XObjects nest before reading stops (they may also refer to themselves). +pub(crate) const MAX_FORM_DEPTH: usize = 12; + +/// The most form XObjects one page's walk enters, at any depth. The depth cap alone still lets +/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 12 +/// levels), which would hang export on a hostile PDF. +pub(crate) const MAX_FORM_VISITS: usize = 4096; + +/// A form XObject a page draws: its object, content, resources and its matrix composed with the +/// CTM at the `Do`. +pub(crate) struct FormCall { + pub(crate) obj: Option, + pub(crate) data: Vec, + pub(crate) resources: Dict, + pub(crate) ctm: Matrix, +} + +/// The form XObject `name` in `resources`, drawn with `ctm`. `None` for images, missing names +/// and forms already being read (`path`), nested too deep, or past the page's `visits` budget +/// ([`MAX_FORM_VISITS`]); each form entered counts one visit. +pub(crate) fn form_call( + doc: &Document, + resources: &Dict, + name: &[u8], + ctm: Matrix, + path: &[pdfcraft_cos::ObjRef], + visits: &mut usize, +) -> Option { + if path.len() >= MAX_FORM_DEPTH || *visits >= MAX_FORM_VISITS { + return None; + } + let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?; + let o = xobjects.get(name)?; + let obj = o.as_ref(); + if obj.is_some_and(|r| path.contains(&r)) { + return None; + } + let Object::Stream(s) = &*doc.resolve(o) else { return None }; + if s.dict.name(b"Subtype") != Some(b"Form") { + return None; + } + let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| { + let a = m.as_array()?; + let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + <[f64; 6]>::try_from(v).ok().filter(|v| v.iter().all(|x| x.is_finite())) + }); + // A form without its own resources uses the ones it was drawn with (PDF 1.1 files). + let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + *visits = visits.saturating_add(1); + Some(FormCall { + obj, + data: s.decoded().unwrap_or_default(), + resources: own.unwrap_or_else(|| resources.clone()), + ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)), + }) +} + +/// The lines a page shows as it reads, including text drawn by form XObjects (which +/// [`text_lines`] leaves out because it can only rewrite the page's own streams). Lines from a +/// form carry a stream number past the page's streams, one per form drawn. +fn reading_lines(doc: &Document, page: usize) -> Result, EditError> { + struct Walk<'a> { + doc: &'a Document, + lines: Vec, + next_stream: usize, + path: Vec, + visits: usize, + } + fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: usize) { + 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)); + let mut last = None; + let mut forms = invoked.into_iter().peekable(); + for s in shown { + while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) { + enter(w, resources, &name, ts); + // Text after a form starts a new line. + last = None; + } + add_shown(&mut w.lines, &mut last, s, stream); + } + for (_, name, ts) in forms { + enter(w, resources, &name, ts); + } + } + fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) { + let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.visits) else { return }; + let ops = parse(&form.data).ops; + // The form starts with the graphics state at its `Do`, its own matrix applied. + let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new() }; + let stream = w.next_stream; + w.next_stream = w.next_stream.saturating_add(1); + if let Some(r) = form.obj { + w.path.push(r); + } + walk(w, &ops, &form.resources, &mut carry, stream); + if form.obj.is_some() { + w.path.pop(); + } + } + let p = page_dict(doc, page)?; + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let streams = content_streams(doc, &p.dict); + let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 }; + let mut carry = Carry::new(); + for (si, (_, data)) in streams.into_iter().enumerate() { + walk(&mut w, &parse(&data).ops, &res, &mut carry, si); + } + let mut lines = w.lines; + lines.retain(|l| !l.text.trim().is_empty()); + Ok(lines) +} + /// Text → the bytes that show it in the chosen font. type Encoder = Box Option>>; @@ -828,6 +1010,14 @@ pub fn text_blocks(doc: &Document, page: usize) -> Result, EditEr Ok(group_blocks(&lines)) } +/// The paragraphs on a page as a reader sees them, including text drawn by form XObjects +/// (Export to Word, HTML and RTF). Read-only: indexes into this list are not +/// [`text_blocks`] indexes, so they can't be passed to [`replace_block`] or [`rewrite_block`]. +pub fn reading_blocks(doc: &Document, page: usize) -> Result, EditError> { + let lines = reading_lines(doc, page)?; + Ok(group_blocks(&lines)) +} + /// Lines that share a left edge, a centre or a right edge (left, centred or right-aligned text). fn aligned(a: &TextLine, b: &TextLine) -> bool { let tol = b.size.max(1.0); diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 7da14c82..c11fc261 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -150,7 +150,8 @@ impl OfficeFormat { } impl crate::Document { - /// The pages as paragraphs and images (for Word, HTML and RTF export). + /// The pages as paragraphs and images (for Word, HTML and RTF export), including what + /// form XObjects draw. pub fn export_pages(&self) -> Vec { let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return Vec::new() }; self.info @@ -158,7 +159,7 @@ impl crate::Document { .iter() .enumerate() .map(|(i, info)| { - let blocks = pdfcraft_edit::text_blocks(cos, i) + let blocks = pdfcraft_edit::reading_blocks(cos, i) .unwrap_or_default() .into_iter() .filter(|b| !b.text.trim().is_empty()) @@ -174,12 +175,11 @@ impl crate::Document { } }) .collect(); - let images = self - .page_images(i) + let images = pdfcraft_edit::reading_images(cos, i) + .unwrap_or_default() .iter() - .enumerate() - .filter_map(|(k, im)| { - let (ext, bytes) = self.page_image_file(i, k).ok()?; + .filter_map(|im| { + let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?; Some(pdfcraft_export::Image { ext: if ext == "jpg" { "jpg" } else { "png" }, bytes, rect: im.rect }) }) .collect(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ad7e0806..0cff7d7c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1761,6 +1761,34 @@ fn exporting_office_files_keeps_images() { assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("data:image/png;base64,")); } +/// #314: a page whose content is drawn through a form XObject exports its text (the file from +/// the report: no xref table, wrong stream lengths). +#[test] +fn exporting_office_files_reads_form_xobjects() { + let pdf = b"%PDF-1.4 +1 0 obj <> endobj +2 0 obj <> endobj +3 0 obj <>>>/Contents 5 0 R>> endobj +4 0 obj <> endobj +5 0 obj <> stream +q 1 0 0 1 0 0 cm /Fm1 Do Q +endstream endobj +7 0 obj <>>>/Length 60>> stream +BT /F1 18 Tf 72 760 Td (Hello from a test invoice) Tj ET +endstream endobj +trailer <> +%%EOF +"; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf.to_vec()), None).unwrap(); + let d = s.get(id).unwrap(); + let blocks: Vec = d.export_pages()[0].blocks.iter().map(|b| b.text.clone()).collect(); + assert_eq!(blocks, ["Hello from a test invoice"]); + assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("Hello from a test invoice")); + // Edit text still offers only what the page's own streams show. + assert!(d.text_blocks(0).is_empty()); +} + #[test] fn exporting_office_files_keeps_text_colour() { // #526: a dark green heading came out black in Word.