From b85d5199938aec0ceff7012688ff82e1852db482 Mon Sep 17 00:00:00 2001 From: MMexicann Date: Tue, 13 Oct 2026 14:16:44 +0000 Subject: [PATCH] M6: preserve whitespace in XML and XFDF attributes Encode tabs and line feeds as character references so XML normalization cannot change field names, comment identities or metadata. Add failing-before-fix saved-PDF round-trip regressions. Co-Authored-By: Codex --- ROADMAP.md | 3 ++ crates/xfdf/README.md | 3 ++ crates/xfdf/src/lib.rs | 3 ++ crates/xfdf/src/tests.rs | 102 +++++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 4 +- 5 files changed, 114 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index a035c9bf3..1dd67e82e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -148,6 +149,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged. - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. - **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged. - **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged. @@ -424,6 +426,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | | 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. | | 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index fd2852584..cf21100aa 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -18,6 +18,9 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o - **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and tab-delimited text (a row of names, a row of values). +XML and XFDF exports preserve tabs and line breaks in field names and comment metadata with +character references, so XML attribute normalization cannot change their identities. + Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws appearances PdfCraft can draw, and sets values through the form's own checks (formats, validation, recalculation); read-only fields take imported values, as in Acrobat. XML is read diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index d9287101b..c7bc2999d 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -197,6 +197,9 @@ fn esc(s: &str) -> String { '<' => o.push_str("<"), '>' => o.push_str(">"), '"' => o.push_str("""), + // XML 1.0 §3.3.3 normalizes literal attribute whitespace, but not references. + '\t' => o.push_str(" "), + '\n' => o.push_str(" "), '\r' => o.push_str(" "), c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {} c => o.push(c), diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index be747d77e..9ec1299e0 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -142,6 +142,108 @@ fn values(doc: &Document) -> Vec<(String, Vec)> { fields(doc).into_iter().map(|f| (f.name, f.value)).collect() } +#[test] +fn xml_exports_preserve_whitespace_in_field_names() { + let names = ["Line\nName", "Line\tName", "Line\rName", "Line\r\nName", "\t Leading\nTrailing \t", "Line Name"]; + let value = "value\twith\nlines\r\nand & \"quotes\""; + let mut src = one_field(); + pdfcraft_forms::delete_field(&mut src, "Name").unwrap(); + for (index, name) in names.iter().enumerate() { + let placeholder = format!("field{index}"); + add_field(&mut src, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: true }, Some(&placeholder)).unwrap(); + // Existing PDFs can have leading/trailing whitespace even though add_field trims it. + let field = fields(&src).into_iter().find(|f| f.name == placeholder).unwrap(); + src.update_dict(field.obj, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + set_value(&mut src, name, &FieldValue::Text(format!("{index}: {value}"))).unwrap(); + } + let source_bytes = write_full(&src, &SaveOptions::default()).unwrap(); + let src = Document::open(Arc::new(source_bytes.clone())).unwrap(); + for format in [Format::Xml, Format::Xfdf] { + let exported = if format == Format::Xfdf { export_xfdf(&src, false, true, "form.pdf") } else { export_data(&src, format) }; + let xml = roxmltree::Document::parse(&exported).unwrap(); + let exported_names: Vec<_> = if format == Format::Xfdf { + xml.descendants().filter(|n| n.has_tag_name("field")).map(|n| n.attribute("name").unwrap()).collect() + } else { + xml.root_element() + .children() + .filter(|n| n.is_element()) + .map(|n| n.attribute(("http://ns.adobe.com/xfdf-transition/", "original")).unwrap()) + .collect() + }; + assert_eq!(exported_names, names, "{format:?}: {exported}"); + let mut dst = Document::open(Arc::new(source_bytes.clone())).unwrap(); + for name in names { + set_value(&mut dst, name, &FieldValue::Text("replace me".into())).unwrap(); + } + let report = import(&mut dst, exported.as_bytes()).unwrap(); + assert_eq!(report.fields, names.len(), "{format:?}: {report:?}"); + assert!(report.rejected.is_empty(), "{report:?}"); + let saved = write_full(&dst, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(values(&reopened), values(&src), "{format:?}"); + } + assert_eq!(write_full(&src, &SaveOptions::default()).unwrap(), source_bytes, "export must not change the source PDF"); +} + +#[test] +fn xfdf_exports_preserve_whitespace_in_comment_attributes() { + let mut src = one_field(); + let shape = Shape::Note { at: [100.0, 500.0], icon: pdfcraft_annot::NoteIcon::Comment }; + let names = ["note\tline\nidentity", "note line identity"]; + let author = "Ada\tLovelace\nReview"; + let subject = "Subject\twith\nlines\r\nand & quotes\""; + for name in names { + add_annotation( + &mut src, + &NewAnnotation { page: 0, style: Style::default_for(&shape), shape: shape.clone(), contents: name.into(), author: author.into() }, + &Meta { id: name.into(), date: None }, + ) + .unwrap(); + } + let annotation_refs = |doc: &Document| { + let page = pdfcraft_model::pages(doc)[0].obj; + doc.get(page) + .as_dict() + .unwrap() + .get(b"Annots") + .map(|a| doc.resolve(a)) + .unwrap() + .as_array() + .unwrap() + .iter() + .filter_map(Object::as_ref) + .filter(|r| doc.get(*r).as_dict().unwrap().name(b"Subtype") == Some(b"Text")) + .collect::>() + }; + for annotation in annotation_refs(&src) { + src.update_dict(annotation, |d| d.set(b"Subj".to_vec(), PdfString::text(subject))).unwrap(); + } + let source_bytes = write_full(&src, &SaveOptions::default()).unwrap(); + let mut dst = Document::open(Arc::new(source_bytes)).unwrap(); + let file = "form\twith\nlines.pdf"; + let xfdf = export_xfdf(&dst, true, false, file); + let xml = roxmltree::Document::parse(&xfdf).unwrap(); + let notes: Vec<_> = xml.descendants().filter(|n| n.has_tag_name("text")).collect(); + assert_eq!(notes.iter().map(|n| n.attribute("name").unwrap()).collect::>(), names); + for note in notes { + assert_eq!(note.attribute("title"), Some(author)); + assert_eq!(note.attribute("subject"), Some(subject)); + } + assert_eq!(xml.descendants().find(|n| n.has_tag_name("f")).unwrap().attribute("href"), Some(file)); + let before = annotation_refs(&dst); + let report = import(&mut dst, xfdf.as_bytes()).unwrap(); + assert_eq!(report.comments, names.len()); + assert_eq!(annotation_refs(&dst), before, "reimport must replace the original comments"); + let saved = write_full(&dst, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(saved)).unwrap(); + for (annotation, name) in annotation_refs(&reopened).iter().zip(names) { + let dict = reopened.get(*annotation); + assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"NM").as_deref(), Some(name)); + assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"T").as_deref(), Some(author)); + assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"Subj").as_deref(), Some(subject)); + } +} + #[test] fn xfdf_round_trips_comments_and_values() { let src = filled(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 04103fb63..417b95aa9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -99,7 +100,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. XML/XFDF exports preserve tabs and line breaks in names and comment metadata.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | | 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). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | 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). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | 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), watermark print/screen visibility and repeat | @@ -161,6 +162,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | | 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged | | 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged | | 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged |