M6: keep AcroForm XML field tags within the NCName grammar (#1588)

Constrain the existing normalization to XML1.0 name characters while preserving
valid tags and original PDF names through xfdf:original. Verify Unicode names,
prefixes and normalization collisions with saved-PDF round trips.

Co-authored-by: MMexicann <MMexicann@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mex
2026-10-15 04:03:00 -04:00
committed by GitHub
co-authored by MMexicann Codex Brandon Thomas Claude Opus 5.5
parent 53b9bc1059
commit e62d3db31b
4 changed files with 80 additions and 3 deletions
+2
View File
@@ -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. |
+23 -3
View File
@@ -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
+54
View File
@@ -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();
+1
View File
@@ -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. |