diff --git a/crates/forms/README.md b/crates/forms/README.md index 66fb919fe..559219527 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -44,6 +44,8 @@ delete_field(&mut doc, "email")?; flattening refuses to make that `?` permanent. - Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances get PdfCraft's own (a drawn check mark or dot; no symbol font needed). +- Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the + original entry is preserved. Positive limits still apply. - Values are validated (options, `MaxLen`, single vs multi-select, NoToggleToOff, read-only) and a failed call changes nothing. - `FieldProps::appearance` applies a `LookPatch` independently to each widget. `None` keeps diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 1dfa76726..18bef8fb8 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -754,7 +754,10 @@ fn walk( inh.q = Some(q); } if let Some(m) = d.get(b"MaxLen").and_then(|o| doc.resolve(o).as_int()) { - inh.max_len = usize::try_from(m).ok(); + // Compatibility: some writers emit zero for an unlimited text field. Keep /MaxLen + // untouched in the object graph, but expose only positive limits to both UI and API. + // An explicit zero also clears an inherited limit rather than inheriting it again. + inh.max_len = usize::try_from(m).ok().filter(|limit| *limit > 0); } let kids: Vec = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index fef5bcfbd..d5b82341f 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2239,6 +2239,22 @@ fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() { assert!(filled.rules.is_empty()); } +#[test] +fn zero_max_len_is_unlimited_and_overrides_inherited_limit() { + let mut doc = fixture(); + let city = fields(&doc).into_iter().find(|f| f.name == "address.city").unwrap(); + let parent = doc.get(city.obj).as_dict().unwrap().get(b"Parent").unwrap().as_ref().unwrap(); + doc.update_dict(parent, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap(); + assert_eq!(fields(&doc).into_iter().find(|f| f.name == "address.city").unwrap().max_len, Some(3)); + doc.update_dict(city.obj, |d| d.set(b"MaxLen".to_vec(), Object::Int(0))).unwrap(); + assert_eq!(fields(&doc).into_iter().find(|f| f.name == "address.city").unwrap().max_len, None); + set_value(&mut doc, "address.city", &FieldValue::Text("Absalom".into())).unwrap(); + let again = reopen(&doc); + assert_eq!(field(&fields(&again), "address.city").value, ["Absalom"]); + assert_eq!(again.get(city.obj).as_dict().unwrap().int(b"MaxLen"), Some(0), "preserve the original entry"); + assert!(set_value(&mut doc, "zip", &FieldValue::Text("123456".into())).is_err(), "positive limits still apply"); +} + #[test] fn auto_text_size_fits_single_and_multiline_text() { use crate::appearance::auto_text_size; diff --git a/docs/form-maxlen-compatibility.md b/docs/form-maxlen-compatibility.md new file mode 100644 index 000000000..adcc02ce6 --- /dev/null +++ b/docs/form-maxlen-compatibility.md @@ -0,0 +1,117 @@ +# AcroForm `/MaxLen 0` compatibility + +Status: implemented; proposed as a separate compatibility PR. Last updated: 2026-10-19. + +- `crates/forms/src/lib.rs::walk` reads inherited `/MaxLen`; the UI and value validation both + consume `Field::max_len`. + +The parser exposed `/MaxLen 0` as `Some(0)`, so both editor and API blocked input. It now +exposes only positive limits; zero clears an inherited limit. The original object entry is +preserved through save/reopen. This is an interoperability interpretation for zero, **not +a claim that ISO explicitly defines zero as unlimited**. ISO 32000-1 Table 229 defines an +optional inheritable maximum length without defining a special zero meaning: +[public specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf). +Positive limits remain enforced; no field names or character-sheet rules enter product code. + +Regression: `crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit`. +The forms README and `form.fill-text` parity entry document this behavior. + +Historical validation recorded before this review: + +- Forms unit suite: all 68 tests passed. The new zero-limit regression was first run against + the old parser and failed (`Some(0)` instead of `None`), then passed with the compatibility + interpretation. Its positive-limit control and preserved raw entry also passed. + +The user reports successful Class text input on the integrated release using their Pathfinder +character sheet. This is a manual functional result, without timings; the private document +was not used for automated regression tests in this review. + +## Independent branch validation + +Validated on upstream `a12fe8f4116984ebbc870eb514346ea922233316` with an isolated +Cargo target directory and no performance changes: + +- The focused regression failed with the original parser (`Some(0)` rather than `None`), + then passed after restoring the compatibility fix. +- `cargo test -p pdfcraft-forms -j 2`: 81 unit tests passed; doc tests passed. +- `cargo clippy -p pdfcraft-forms -j 2 --all-targets -- -D warnings`: passed. +- `cargo check --target wasm32-unknown-unknown -p pdfcraft-forms -j 2`: passed. +- `cargo fmt --check`, `git diff --check`, and `cargo xtask layers`, `assets`, `parity`: passed. + +These are local checks, not hosted CI. No private PDF was used. + +## Rebase validation + +Rebased onto freshly fetched upstream main +`932bf9c6bfdd63e2a21bb831696a3447b9def9cf` on 2026-10-18. The only conflict was +in the M6 roadmap row; upstream CSV/XML updates were retained alongside the compatibility +note. The implementation and original regression are unchanged from the independent branch. + +Validation used a fresh Cargo target directory, two build jobs, and locked, offline builds: + +- The original regression failed with the upstream parser (`Some(0)` instead of `None`), + then passed with the compatibility fix restored. +- Forms: 83 unit tests passed. Engine: 282 unit and 9 integration tests passed. + JavaScript: 47 unit tests passed. Doc tests passed. The timing probe and two private-file + tests remained ignored. +- The automation `javascript_through_tools` regression passed. +- Scoped Clippy for forms, engine and JavaScript, with `--all-targets -- -D warnings`, passed. +- WASM checks for those three crates passed. +- Rust formatting, whitespace, layering, assets and parity checks passed. +- A freshly built CLI filled synthetic fields with direct and inherited `/MaxLen 0`, rendered + their values, saved/reopened them and accepted longer text after reopening. Its positive-limit + control rejected six characters for `/MaxLen 5`; the synthetic input file stayed unchanged. + +No performance optimizations, JavaScript snapshot/diff changes or profiling instrumentation +are included. Existing executables, backups and private PDFs were not modified. + +## PR #3323 refresh + +Revalidated against freshly fetched upstream main +`4db6ac4ae2448df6389d8bb0bc54db6ba175721c` on 2026-10-18. Conflicts in both +roadmaps were resolved by keeping upstream's button-label and content-editing notes alongside +the `/MaxLen 0` documentation. The original compatibility implementation and regression are +preserved; no performance changes were added to the PR. + +Forms: 85 unit tests passed. Engine: 283 unit and 15 integration tests passed. JavaScript: +48 unit tests passed. The automation JavaScript regression, scoped Clippy with warnings denied, +WASM, formatting, whitespace, layering, assets and parity checks all passed. The synthetic CLI +fill/render/save/reopen and positive-limit control passed. The original regression again failed +with the old parser and passed with the fix restored. Private-file tests remained ignored. + +## 2026-10-19 revalidation + +Rebased onto freshly fetched upstream main +`459379ef9b127648cc30c0b2fe2b20c2b2375f04`. Only `ROADMAP.md` required manual +conflict resolution; all upstream entries and the compatibility note were retained. +The implementation, original regression and forms README remain byte-for-byte identical +to the previously reviewed compatibility head. No performance changes were introduced. + +Validation used a fresh isolated build directory, two jobs, and locked offline builds: + +- The original regression failed with the old parser and passed with the fix restored. +- Forms: 93 unit tests passed. Engine: 283 unit and 15 integration tests passed. + JavaScript: 48 tests passed. Doc tests passed; the timing probe and two private-file + tests remained ignored. +- Automation JavaScript, scoped all-targets Clippy with warnings denied, WASM, formatting, + whitespace, layering, assets and parity passed. +- Synthetic CLI fill/render/save/reopen checks passed for direct zero and zero overriding + an inherited positive limit. Longer text after reopening was accepted. The `/MaxLen 5` + control rejected six characters, and the synthetic input stayed unchanged. + +These are local checks, not hosted CI. Existing executables, backups and private PDFs +were not modified; only the compatibility worktree was changed. + +## Revision history + +- 2026-10-19: refresh PR #3323 onto upstream `459379ef`, retain all upstream roadmap entries + and independently repeat regression, native/WASM, repository and CLI validation. + +- 2026-10-18: refresh PR #3323 onto upstream `4db6ac4a`, retaining both roadmap updates and + independently revalidating compatibility and repository checks. + +- 2026-10-18: rebase onto fetched upstream `932bf9c6`; preserve upstream roadmap updates and + independently revalidate the regression, forms/engine/JavaScript suites and quality gates. + +- 2026-10-18: isolate the compatibility change on current upstream main; verify the + regression before and after the fix, the forms suite and scoped quality gates. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8..e06a5c7f1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4416,8 +4416,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value"] -notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4)" +evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit"] +notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced." [[feature]] id = "form.fill-checkbox-radio"