mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 04:28:20 -06:00
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:
co-authored by
MMexicann
Codex
Brandon Thomas
Claude Opus 5.5
parent
53b9bc1059
commit
e62d3db31b
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user