mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 02:58:32 -06:00
Merge PR #1917: M6: preserve whitespace in XML and XFDF attributes
ROADMAP.md: next's file; the PR's progress line and revision row added once; refresh line and status-line edits dropped. docs/roadmap.md: next's file; the PR's revision row and M6 milestone sentence added once; refresh note and status-line edit dropped.
This commit is contained in:
@@ -157,6 +157,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
|
||||
|
||||
## 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-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
|
||||
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
|
||||
@@ -495,6 +496,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-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
|
||||
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -142,6 +142,108 @@ fn values(doc: &Document) -> Vec<(String, Vec<String>)> {
|
||||
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 <XML> & \"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::<Vec<_>>()
|
||||
};
|
||||
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::<Vec<_>>(), 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();
|
||||
|
||||
+2
-1
@@ -117,7 +117,7 @@ total matches the parity estimate.
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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 M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
|
||||
| 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.<br>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.<br>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; XFDF values retain text across XML comments and processing instructions.<br>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<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| 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; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata.<br>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<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| 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.<br>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.<br>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 |
|
||||
@@ -179,6 +179,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-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
|
||||
|
||||
@@ -4160,8 +4160,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.export"]
|
||||
tools = ["doc_export_data"]
|
||||
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
|
||||
notes = ""
|
||||
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xfdf_exports_preserve_whitespace_in_comment_attributes"]
|
||||
notes = "XFDF exports retain tabs and line breaks in comment identities and metadata."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.export-selected"
|
||||
@@ -4692,8 +4692,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.export_data"]
|
||||
tools = ["doc_export_data"]
|
||||
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
|
||||
notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data)"
|
||||
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"]
|
||||
notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data); XML/XFDF exports retain tabs and line breaks in field identities through character references."
|
||||
|
||||
[[feature]]
|
||||
id = "form.data-xml-csv-txt"
|
||||
@@ -4704,8 +4704,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.export_data", "form.import_data"]
|
||||
tools = ["doc_export_data", "doc_import_data"]
|
||||
evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
|
||||
notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited"
|
||||
evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"]
|
||||
notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited; XML/XFDF exports retain tabs and line breaks in field identities through character references."
|
||||
|
||||
[[feature]]
|
||||
id = "form.merge-to-spreadsheet"
|
||||
|
||||
Reference in New Issue
Block a user