diff --git a/ROADMAP.md b/ROADMAP.md index 332ba2733..e71d88dc7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,6 +164,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged. - **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged. - **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged. - **2026-10-12 (community, M14, NixOS UI fonts):** Linux/Unix interface fallback fonts are also discovered in Fontconfig's configured directories, including Nix store and user font paths. Existing candidate names, glyph checks, size limits and the screenshot opt-out are retained. Synthetic regressions cover included configuration, symlinks, cycles, depth limits and candidate ordering. Estimates unchanged. @@ -488,6 +489,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 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. | | 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. | | 2026-10-14 | minor | M7/#128: Hebrew in Add text with the craft-fonts Hebr face (display order, points, ToUnicode, ActualText); estimates unchanged. | diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 3c3ebe3f0..50326c4e7 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -558,10 +558,30 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V let _ = write!(out, "trailer\n<< /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n"); out } -/// The XML element name for a field name (letters, digits, `_`, `-`, `.`). +/// XML 1.0 fifth-edition NameStartChar, excluding `:` for an unprefixed NCName. +/// https://www.w3.org/TR/2008/REC-xml-20081126/#NT-NameStartChar +fn xml_name_start(c: char) -> bool { + matches!( + c, + '_' | 'A'..='Z' | 'a'..='z' + | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' + | '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' + | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' + | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' + | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' + | '\u{10000}'..='\u{EFFFF}' + ) +} + +fn xml_name_char(c: char) -> bool { + xml_name_start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}') +} + +/// Keep the existing letters/digits/`_`/`-`/`.` normalization, constrained to valid NCNames. fn xml_name(name: &str) -> String { - let mut s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect(); - if !s.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') { + let mut s: String = + name.chars().map(|c| if (c.is_alphanumeric() && xml_name_char(c)) || matches!(c, '_' | '-' | '.') { c } else { '_' }).collect(); + if !s.chars().next().is_some_and(|c| (c.is_alphabetic() && xml_name_start(c)) || c == '_') { s.insert(0, '_'); } s diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 4e876468c..d244df1ad 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -292,6 +292,60 @@ trailer\n<< /Root 1 0 R >>\n%%EOF\n"; assert_eq!((parent("cycle-a").as_deref(), parent("cycle-b").as_deref()), (Some("cycle-b"), Some("cycle-a"))); } +#[test] +fn xml_field_names_remain_valid_and_round_trip_without_collisions() { + assert_eq!(xml_name(".Value"), "_.Value"); + let names = [ + ("Area²", "Area_"), + ("Area_", "Area_"), + ("Aµ", "A_"), + ("µRate", "_Rate"), + ("²Rate", "_Rate"), + ("½Amount", "_Amount"), + ("1Rate", "_1Rate"), + ("Café", "Café"), + ("Δείγμα", "Δείγμα"), + ("姓名", "姓名"), + ("٣Value", "_٣Value"), + ("\u{345}Rate", "_\u{345}Rate"), + ("A\u{345}", "A\u{345}"), + ("Alpha_1-2", "Alpha_1-2"), + ("ns:value", "ns_value"), + ("-Value", "_-Value"), + ]; + let mut src = one_field(); + pdfcraft_forms::delete_field(&mut src, "Name").unwrap(); + for (index, (name, _)) in names.iter().enumerate() { + add_field(&mut src, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some(name)).unwrap(); + set_value(&mut src, name, &FieldValue::Text(format!("value-{index}"))).unwrap(); + } + let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap()); + let src = Document::open(source.clone()).unwrap(); + let want = values(&src); + for format in [Format::Xml, Format::Xdp] { + let text = export_data(&src, format); + let xml = roxmltree::Document::parse(&text).unwrap(); + let children: Vec<_> = xml.root_element().children().filter(|n| n.is_element()).collect(); + assert_eq!(children.len(), names.len()); + for (child, (original, tag)) in children.iter().zip(names) { + assert_eq!(child.tag_name().name(), tag); + assert_eq!(child.attribute(("http://ns.adobe.com/xfdf-transition/", "original")), (original != tag).then_some(original)); + } + assert!(!src.is_modified(), "export must not change the source PDF"); + assert_eq!(src.bytes().as_slice(), source.as_slice()); + + let mut dst = Document::open(source.clone()).unwrap(); + for (name, _) in names { + set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap(); + } + let report = import(&mut dst, text.as_bytes()).unwrap(); + assert_eq!(report.fields, names.len()); + assert!(report.rejected.is_empty(), "{report:?}"); + let reopened = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(values(&reopened), want, "{format:?}: sanitized names must preserve distinct original fields"); + } +} + #[test] fn form_data_as_xml_csv_and_text() { let src = filled(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 5bdefc375..3148823ea 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -179,6 +179,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 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. | | 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. | | 2026-10-12 | minor | M14/#1395: XDG configuration preferences, safe migration and fallback; logs/recovery retain data locations, portable/non-XDG unchanged. Estimates unchanged. |