mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude
Brandon Thomas
parent
84bd488db3
commit
5602fbba12
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user