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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Fabian
2026-10-19 13:02:44 -04:00
committed by GitHub
co-authored by Claude Brandon Thomas
parent 84bd488db3
commit 5602fbba12
11 changed files with 273 additions and 38 deletions
+1
View File
@@ -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.
+13 -3
View File
@@ -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<usize> = 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<pdfcraft_engine::pdfa::Level> {
+1 -1
View File
@@ -1065,7 +1065,7 @@ pub fn tools() -> Vec<ToolDef> {
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.")
+68
View File
@@ -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<u8> {
let mut objs: Vec<String> =
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<usize> = html.split("<hr>").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() {
+3 -3
View File
@@ -300,7 +300,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
/// The forms being read (cycle guard) and how many were entered (the page's budget).
struct Forms {
path: Vec<ObjRef>,
visits: usize,
budget: crate::text::FormBudget,
}
fn walk(
doc: &Document,
@@ -317,7 +317,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, 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<Vec<PageImage>, 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)
+2 -2
View File
@@ -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::{
+82
View File
@@ -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::<Vec<_>>())
}
/// #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<String> = 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]
+58 -18
View File
@@ -1271,13 +1271,27 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, 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<FormCall> {
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<f64> = 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<Vec<TextLine>, 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<TextLine>, bool), EditError> {
struct Walk<'a> {
doc: &'a Document,
lines: Vec<TextLine>,
next_stream: usize,
path: Vec<pdfcraft_cos::ObjRef>,
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<Vec<TextLine>, 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<Vec<TextLine>, 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<Vec<TextLine>, 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<Vec<TextBlock>, 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<TextBlock>,
/// 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<ReadingBlocks, EditError> {
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.
+42 -8
View File
@@ -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<u8>,
/// 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<usize>,
}
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<pdfcraft_export::Page> {
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<pdfcraft_export::Page>, Vec<usize>) {
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<u8> {
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 }
}
}
+1 -1
View File
@@ -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.<br>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.<br>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).<br>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).<br>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.<br>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<br>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. |
+2 -2
View File
@@ -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"