From 5602fbba12d74bc28bbfa101e4061d5d0e3a6f3c Mon Sep 17 00:00:00 2001 From: Fabian <68284158+Faabyy@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:02:44 +0200 Subject: [PATCH] M10: Office export follows forms 50 deep and up to 50 000 a page and says where it stops (#3120) (#3426) * M10: Office export reads forms as far as the renderer and says where it stops (#3120) Export to Word, PowerPoint, HTML and RTF stopped at 12 nested forms and 4 096 forms a page and left the rest out silently. It now follows forms 50 deep, as the renderer does, and up to 50 000 a page (the renderer's budget for nested paints), still bounded on hostile files. doc_export_office lists pages past those limits in truncated_pages, with a warning. New: reading_blocks_checked and export_office_checked; reading_blocks and export_office keep their signatures. Fixes #3120 Co-Authored-By: Claude * Tests: pin the form depth limit to the renderer's 50 (#3120) The depth test built its chain from MAX_FORM_DEPTH, so it moved with the constant. Write the renderer's limit out: depth 50 is read, depth 51 is left out and reported. Fails with the constant at 49 and at 51. Co-Authored-By: Claude * Export: say which forms count toward the page limit (#3120) The page budget also counts forms the page draws itself, which the renderer does not charge, so a page can be reported as cut although the viewer shows it. The warning and the tool description now say so. Co-Authored-By: Claude --------- Co-authored-by: Claude Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/automation/src/a11y.rs | 16 +++++- crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 68 ++++++++++++++++++++++ crates/edit/src/images.rs | 6 +- crates/edit/src/lib.rs | 4 +- crates/edit/src/tests.rs | 82 +++++++++++++++++++++++++++ crates/edit/src/text.rs | 76 +++++++++++++++++++------ crates/engine/src/compare.rs | 50 +++++++++++++--- docs/roadmap.md | 2 +- parity/acrobat-features.toml | 4 +- 11 files changed, 273 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e06e4830c..1a2968e2f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -175,6 +175,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. +- **2026-10-18 (M10, #3120):** Export to Word, PowerPoint, HTML and RTF reads text from form XObjects nested as deep as the renderer draws them (50 levels), and up to 50 000 forms a page (the renderer's budget for nested ones); it stopped at 12 levels and 4 096 forms and left the rest out silently. A page past those limits is listed in `doc_export_office`'s `truncated_pages`, with a warning. Estimates unchanged. - **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged. - **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved. diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 8130ed084..212454973 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -710,9 +710,19 @@ impl Automation { let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?; - let bytes = self.doc_to_copy(a)?.export_office(format); - write_atomic(&path, &bytes)?; - Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() })) + let export = self.doc_to_copy(a)?.export_office_checked(format); + write_atomic(&path, &export.bytes)?; + // Text left out to keep a hostile page bounded is said, not silent (#3120). + let truncated: Vec = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect(); + let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(), + "truncated": !truncated.is_empty(), "truncated_pages": truncated }); + if !export.truncated_pages.is_empty() { + use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; + out["warnings"] = json!([format!( + "forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them" + )]); + } + Ok(out) } fn pdfa_level(&self, a: &Args) -> Result { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 91cd489c0..df7ca9100 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1065,7 +1065,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), &["doc", "field", "event"], )), - t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.") + t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.") .cmd("export.docx") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index f3bcfd21a..2f0b02684 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5282,6 +5282,74 @@ fn exporting_to_word_html_and_rtf() { assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err()); } +/// #3120: a PDF whose pages draw "Head", then a chain of `depths[page]` nested forms whose +/// innermost writes "TAIL". +fn nested_forms_pdf(depths: &[usize]) -> Vec { + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), String::new(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()]; + let mut kids = Vec::new(); + for &depth in depths { + // The object number of D1, D2 and so on. + let first = objs.len() + 1; + let xobjects: String = (1..=depth).map(|i| format!("/D{i} {} 0 R ", first + i - 1)).collect(); + let resources = format!("<< /Font << /F1 3 0 R >> /XObject << {xobjects}>> >>"); + for i in 1..=depth { + let body = if i == depth { "BT /F1 24 Tf 20 150 Td (TAIL) Tj ET".to_string() } else { format!("/D{} Do", i + 1) }; + objs.push(format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 300] /Resources {resources} /Length {} >>\nstream\n{body}\nendstream", + body.len() + )); + } + let body = "BT /F1 24 Tf 20 250 Td (Head) Tj ET /D1 Do"; + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", objs.len())); + kids.push(format!("{} 0 R", objs.len())); + } + objs[1] = format!("<< /Type /Pages /Kids [{}] /Count {} /MediaBox [0 0 200 300] >>", kids.join(" "), kids.len()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + 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{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #3120: export reads forms as deeply nested as the renderer draws them (13 used to lose their +/// text), and its result says which pages draw forms past that, whose text is missing. +#[test] +fn exporting_office_files_says_which_pages_left_form_text_out() { + use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; + let dir = workdir("office-forms"); + std::fs::write(dir.join("deep.pdf"), nested_forms_pdf(&[13, MAX_FORM_DEPTH + 1, MAX_FORM_DEPTH])).unwrap(); + std::fs::write(dir.join("fine.pdf"), nested_forms_pdf(&[13])).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "deep.pdf" }))["doc"].as_u64().unwrap(); + for ext in ["html", "docx"] { + let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("deep.{ext}") })); + assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(true), &json!([2])), "{r}"); + let warning = r["warnings"][0].as_str().unwrap(); + assert!(warning.contains(&format!("{MAX_FORM_DEPTH} deep")) && warning.contains(&MAX_FORM_VISITS.to_string()), "{warning}"); + assert!(warning.contains("truncated_pages"), "{warning}"); + } + let html = std::fs::read_to_string(dir.join("deep.html")).unwrap(); + let tails: Vec = html.split("
").map(|page| page.matches("TAIL").count()).collect(); + assert_eq!(tails, [1, 0, 1], "{html}"); + assert_eq!(html.matches("Head").count(), 3, "{html}"); + + let fine = ok(&mut a, "doc_open", json!({ "path": "fine.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_export_office", json!({ "doc": fine, "path": "fine.html" })); + assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(false), &json!([])), "{r}"); + assert!(r.get("warnings").is_none(), "{r}"); + assert!(std::fs::read_to_string(dir.join("fine.html")).unwrap().contains("TAIL")); +} + /// #773: a diagonal watermark must not enter the body flow or move invoice fields. #[test] fn exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow() { diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 79f2c2cd2..e90d8e4f1 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -300,7 +300,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi /// The forms being read (cycle guard) and how many were entered (the page's budget). struct Forms { path: Vec, - visits: usize, + budget: crate::text::FormBudget, } fn walk( doc: &Document, @@ -317,7 +317,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let ctm = st.placement.matrix; if op.is("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(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.budget) { if let Some(r) = form.obj { forms.path.push(r); } @@ -367,7 +367,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi 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 = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let mut forms = Forms { path: Vec::new(), visits: 0 }; + let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; let pieces = joined(&streams(doc, &p.dict)); walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index fcf2f1997..c1062b410 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -859,8 +859,8 @@ pub mod indic; mod linebreak; pub mod text; pub use text::{ - BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, - text_lines, + BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, clear_block, reading_blocks, + reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, }; pub mod added; pub use added::{ diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ac46fcd4a..691e01c98 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2982,6 +2982,88 @@ fn form_xobjects_that_fan_out_stop_at_the_visit_budget() { assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed()); } +/// #3120: a one-page document whose page draws "Secret", then `content`. `forms` are (name, +/// content) form XObjects, which the page and every form can draw, as they can a 1×1 image `/Im`. +fn page_with_forms(content: &str, forms: &[(String, String)]) -> Document { + let xobjects: String = forms.iter().enumerate().map(|(i, (name, _))| format!("/{name} {} 0 R ", 7 + i)).collect(); + let resources = format!("<< /Font << /F1 5 0 R >> /XObject << /Im 6 0 R {xobjects}>> >>"); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources {resources} /Contents 4 0 R >>"), + stream("", &format!("BT /F1 24 Tf 72 700 Td (Secret) Tj ET\n{content}")), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"), + ]; + objs.extend(forms.iter().map(|(_, body)| stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources {resources}"), body))); + build(&objs.iter().map(String::as_str).collect::>()) +} + +/// #3120: export reads text from forms as deep and as often as the renderer draws them, and says +/// when a limit left some out: a text form drawn 5 000 times (past the old budget of 4 096) or +/// forms nested 13 deep (past the old 12) lost the form drawn after them, silently. +#[test] +fn reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops() { + // Covers "Secret" with a black box and writes "TAIL": drawn last, so a reader that stops + // early misses it. + const TAIL: &str = "0 g 70 692 90 30 re f BT /F1 24 Tf 72 600 Td (TAIL) Tj ET"; + let tail = |doc: &Document| { + let read = text::reading_blocks_checked(doc, 0).unwrap(); + (read.blocks.iter().any(|b| b.text.contains("TAIL")), read.truncated) + }; + let form = |name: &str, body: &str| (name.to_string(), body.to_string()); + + let rows = + |n: usize| page_with_forms(&format!("{}/C Do", "/R Do ".repeat(n)), &[form("R", "BT /F1 6 Tf 300 100 Td (row) Tj ET"), form("C", TAIL)]); + assert_eq!(tail(&rows(5_000)), (true, false)); + + // The innermost form also draws an image: an image at the depth limit is read, and leaves + // nothing out. + let chain = |depth: usize| { + let mut forms: Vec<_> = (1..depth).map(|i| form(&format!("D{i}"), &format!("/D{} Do", i + 1))).collect(); + forms.push(form(&format!("D{depth}"), &format!("{TAIL} q 20 0 0 10 5 5 cm /Im Do Q"))); + page_with_forms("/D1 Do", &forms) + }; + assert_eq!(tail(&chain(13)), (true, false)); + // The renderer's limit, written out so the test does not move with the constant. + assert_eq!(tail(&chain(50)), (true, false)); + assert_eq!(tail(&chain(51)), (false, true)); + let deepest = chain(text::MAX_FORM_DEPTH); + assert_eq!(tail(&deepest), (true, false)); + assert_eq!(images::reading_images(&deepest, 0).unwrap().len(), 1); + let deep = chain(text::MAX_FORM_DEPTH + 1); + assert_eq!(tail(&deep), (false, true)); + assert!(images::reading_images(&deep, 0).unwrap().is_empty()); + + // A wrapper form A draws a dot form B `n` times, then C. A, the dots and C use the page's + // budget up exactly; one dot more leaves C unread. + let fanout = + |n: usize| page_with_forms("/A Do", &[form("A", &format!("{}/C Do", "/B Do ".repeat(n))), form("B", "0 g 10 10 1 1 re f"), form("C", TAIL)]); + assert_eq!(tail(&fanout(text::MAX_FORM_VISITS - 2)), (true, false)); + let many = fanout(text::MAX_FORM_VISITS - 1); + assert_eq!(tail(&many), (false, true)); + assert!(images::reading_images(&many, 0).unwrap().is_empty()); + // Without the flag, reading is unchanged. + let blocks: Vec = text::reading_blocks(&many, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(blocks, ["Secret"]); +} + +/// #3120: with the renderer's limits, forms that each draw the next a hundred times, 12 levels +/// deep (100^11 forms) or past the depth cap, still stop at the page's budget quickly, and say +/// that text was left out. +#[test] +fn forms_that_fan_out_a_hundred_ways_stop_quickly_and_say_so() { + for levels in [12, text::MAX_FORM_DEPTH + 1] { + let mut forms: Vec<(String, String)> = (1..levels).map(|i| (format!("N{i}"), format!("/N{} Do ", i + 1).repeat(100))).collect(); + forms.push((format!("N{levels}"), "BT /F1 10 Tf 10 10 Td (leaf) Tj ET".to_string())); + let doc = page_with_forms("/N1 Do", &forms); + let start = std::time::Instant::now(); + assert!(text::reading_blocks_checked(&doc, 0).unwrap().truncated, "{levels} levels"); + assert!(images::reading_images(&doc, 0).unwrap().is_empty()); + assert!(start.elapsed() < std::time::Duration::from_secs(30), "{levels} levels 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] diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index f547e5efd..e93ead68f 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1271,13 +1271,27 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream *last = Some(Pen::after(&s)); } -/// How deep form XObjects nest before reading stops (they may also refer to themselves). -pub(crate) const MAX_FORM_DEPTH: usize = 12; +/// How deep form XObjects nest before reading stops (they may also refer to themselves). The +/// renderer stops at the same depth, so export reads the text the page shows (#3120). +pub const MAX_FORM_DEPTH: usize = 50; /// 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 that draws another form many times fan out exponentially (100 `Do`s on each of 50 +/// levels), which would hang export on a hostile PDF. The renderer paints as many forms nested +/// in forms; this budget also counts the forms the page draws itself, so a page that draws one +/// a million times stays bounded too. A label or table cell drawn as a form a few thousand +/// times is read in full (#3120). +pub const MAX_FORM_VISITS: usize = 50_000; + +/// One page's walk through the form XObjects it draws: how many it entered (the page's +/// [`MAX_FORM_VISITS`]), and whether a limit left a form unread. +#[derive(Default)] +pub(crate) struct FormBudget { + visits: usize, + /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past + /// [`MAX_FORM_VISITS`]: what it draws is missing from the walk. + pub(crate) truncated: bool, +} /// A form XObject a page draws: its object, content, resources and its matrix composed with the /// CTM at the `Do`. @@ -1289,17 +1303,19 @@ pub(crate) struct FormCall { } /// 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. +/// and forms already being read (`path`), and for forms nested too deep or past the page's +/// budget, which `budget` records as truncated; 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, + budget: &mut FormBudget, ) -> Option { - if path.len() >= MAX_FORM_DEPTH || *visits >= MAX_FORM_VISITS { + let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS; + // Once a limit has cut the page short, refusing the rest costs nothing more. + if limited && budget.truncated { return None; } let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?; @@ -1312,6 +1328,11 @@ pub(crate) fn form_call( if s.dict.name(b"Subtype") != Some(b"Form") { return None; } + // Only a form left unread loses content; an image at the limit is still read by its caller. + if limited { + budget.truncated = true; + 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(); @@ -1319,7 +1340,7 @@ pub(crate) fn form_call( }); // 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); + budget.visits = budget.visits.saturating_add(1); Some(FormCall { obj, data: s.decoded().unwrap_or_default(), @@ -1330,14 +1351,15 @@ pub(crate) fn form_call( /// 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> { +/// form carry a stream number past the page's streams, one per form drawn. The flag is `true` +/// when forms past [`MAX_FORM_DEPTH`] or [`MAX_FORM_VISITS`] were left unread. +fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), EditError> { struct Walk<'a> { doc: &'a Document, lines: Vec, next_stream: usize, path: Vec, - visits: usize, + budget: FormBudget, } /// `stream` gives the stream number of the operator at an index of `ops`. fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) { @@ -1367,7 +1389,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError } } 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 Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) 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(), marked: Vec::new() }; @@ -1384,7 +1406,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError 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 w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() }; let mut carry = Carry::new(); // Read the page's streams as the one stream they are, in one pass, as rendering and editing // do: a comment, an operator's operands or a marked-content sequence (its /ActualText too) @@ -1400,7 +1422,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError l.text = crate::indic::to_logical(&l.text); } lines.retain(|l| !l.text.trim().is_empty()); - Ok(lines) + Ok((lines, w.budget.truncated)) } /// Text → the runs that show it: each run's font resource name and the bytes shown in that font. @@ -2016,9 +2038,27 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), /// 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`]. +/// [`reading_blocks_checked`] also says whether text was left out. pub fn reading_blocks(doc: &Document, page: usize) -> Result, EditError> { - let lines = reading_lines(doc, page)?; - Ok(regions::group_reading_blocks(&lines)) + reading_blocks_checked(doc, page).map(|r| r.blocks) +} + +/// A page's paragraphs as a reader sees them ([`reading_blocks_checked`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ReadingBlocks { + /// The paragraphs, as [`reading_blocks`] returns them. + pub blocks: Vec, + /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], or more of them than + /// [`MAX_FORM_VISITS`]. Reading leaves those out to stay bounded on hostile files, so their + /// text is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses + /// their images too. + pub truncated: bool, +} + +/// [`reading_blocks`], saying whether the limits that bound reading left text out. +pub fn reading_blocks_checked(doc: &Document, page: usize) -> Result { + let (lines, truncated) = reading_lines(doc, page)?; + Ok(ReadingBlocks { blocks: regions::group_reading_blocks(&lines), truncated }) } // An ideographic space carries intentional Japanese spacing, including paragraph indentation. diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 3c03870f3..f0ed2b4b8 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -4,6 +4,9 @@ use std::sync::Arc; pub use pdfcraft_compare::{Change, Comparison, Kind, Side}; +// How far Office export reads into form XObjects; `OfficeExport::truncated_pages` lists the pages +// that draw more. +pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape}; @@ -151,12 +154,31 @@ impl OfficeFormat { } } +/// A Word, PowerPoint, HTML or RTF export ([`crate::Document::export_office_checked`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct OfficeExport { + /// The exported file. + pub bytes: Vec, + /// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them + /// than [`MAX_FORM_VISITS`]: export stops reading there to stay bounded on hostile files, so + /// the text and images of those forms are missing from `bytes`. + pub truncated_pages: Vec, +} + impl crate::Document { /// 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 + self.export_pages_checked().0 + } + + /// [`Self::export_pages`], and the pages whose forms were left partly unread + /// ([`OfficeExport::truncated_pages`]). + fn export_pages_checked(&self) -> (Vec, Vec) { + let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) }; + let mut truncated = Vec::new(); + let pages = self + .info .pages .iter() .enumerate() @@ -171,8 +193,12 @@ impl crate::Document { let crop_width = f64::from(info.crop[2] - info.crop[0]); let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height }); let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 }; - let blocks = pdfcraft_edit::reading_blocks(cos, i) - .unwrap_or_default() + let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default(); + if reading.truncated { + truncated.push(i); + } + let blocks = reading + .blocks .into_iter() .filter(|b| !b.text.trim().is_empty()) .map(|b| { @@ -204,18 +230,26 @@ impl crate::Document { .collect(); pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images } }) - .collect() + .collect(); + (pages, truncated) } /// The document as a Word, PowerPoint, HTML or RTF file. pub fn export_office(&self, format: OfficeFormat) -> Vec { - let pages = self.export_pages(); + self.export_office_checked(format).bytes + } + + /// [`Self::export_office`], and the pages whose text it had to leave out + /// ([`OfficeExport::truncated_pages`]). + pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport { + let (pages, truncated_pages) = self.export_pages_checked(); let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string()); - match format { + let bytes = match format { OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title), OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title), OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(), OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(), - } + }; + OfficeExport { bytes, truncated_pages } } } diff --git a/docs/roadmap.md b/docs/roadmap.md index 16622626b..19bfac9f1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -144,7 +144,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a0aa9096b..516bbd752 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7088,8 +7088,8 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] -notes = "Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Not yet: multi-page folder output, CSS from fonts." +evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] +notes = "Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] id = "create.export-text"