From ff62683ff80f90d520f0ac4439d4b34ed2b2956b Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:18:58 +0300 Subject: [PATCH 01/47] M12: skip PDF container scripts during active XFA layout (#2589) --- ROADMAP.md | 3 ++ crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 29 +++++++++++ crates/engine/README.md | 8 +++ crates/engine/src/js.rs | 8 ++- crates/engine/src/tests.rs | 72 +++++++++++++++++++++++++++ crates/xfa/README.md | 5 +- docs/roadmap.md | 6 ++- parity/acrobat-features.toml | 4 +- 9 files changed, 130 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fe9723fdf6..0b48c519ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2589: active XFA skips PDF-container script initialization; requested messages and static/ordinary PDF scripts remain; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -156,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-15 (M12, #2589):** Active XFA layout no longer initializes the Acrobat console/field context with PDF-container document scripts, avoiding misleading fallback-viewer alerts. Scripts remain inspectable and saved; explicit console/field and XFA messages remain, as do ordinary/static PDF helpers. Synthetic engine and tool tests cover save/reopen and static-conversion undo. 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. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). @@ -490,6 +492,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M12 #2589: active XFA skips PDF-container script initialization; requested messages and static/ordinary PDF scripts remain; 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. | | 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. | diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5e89aef78a..76e34de248 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1041,7 +1041,7 @@ pub fn tools() -> Vec { t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), - t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.") + t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.") .cmd("tools.js_console") .with(schema( json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f38..9ab1292eb9 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5434,6 +5434,35 @@ fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() { assert_eq!(r["alerts"], json!(["Hello 4"])); } +#[test] +fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() { + let dir = workdir("xfa-container-scripts"); + std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "form.pdf"}))["doc"].as_u64().unwrap(); + let fallback = "app.alert('This PDF form requires a newer version of Adobe Acrobat');"; + ok(&mut a, "js_set_document_script", json!({"doc":doc,"name":"viewerFallback","script":fallback})); + let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"app.alert('Console message');"})); + assert!(out["error"].is_null(), "{out}"); + assert_eq!(out["alerts"], json!(["Console message"])); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"})); + ok(&mut a, "doc_close", json!({"doc":doc})); + let doc = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "js_document_scripts", json!({"doc":doc}))["scripts"], json!([{"name":"viewerFallback","script":fallback}])); + let out = ok(&mut a, "js_run", json!({"doc":doc,"script":""})); + assert!(out["error"].is_null(), "{out}"); + assert_eq!(out["alerts"], json!([])); + let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"","field":"hello"})); + assert!(out["error"].is_null(), "{out}"); + assert_eq!(out["alerts"], json!(["Hello 2"])); + ok(&mut a, "xfa_to_static", json!({"doc":doc})); + let out = ok(&mut a, "js_run", json!({"doc":doc,"script":""})); + assert_eq!(out["alerts"].as_array().unwrap().len(), 1); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(ok(&mut a, "js_run", json!({"doc":doc,"script":""}))["alerts"], json!([])); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off() { let dir = workdir("xfa-scripts-off"); diff --git a/crates/engine/README.md b/crates/engine/README.md index 4bf80e3f73..86492bc043 100644 --- a/crates/engine/README.md +++ b/crates/engine/README.md @@ -16,6 +16,14 @@ Two frontends exist on top of it today: `pdfcraft-ui-egui` (desktop and web) and `pdfcraft-automation` (headless tools, the CLI and MCP). Anything one can do, the other must be able to do — see AGENTS.md §3. +When dynamic XFA layout is active, `JsRunner` does not initialize Acrobat JavaScript with +scripts from the PDF container's `/Names /JavaScript` tree. Those scripts belong to the +fallback PDF view and can otherwise show a misleading viewer-compatibility warning. +They remain in the document and in `Document::document_scripts`. Requested console scripts, +field actions and XFA events can still show messages; ordinary PDFs, static XFA and shells +without an active layout keep their document scripts. Converting XFA to a static form and +undoing that conversion switches the context with the document state. + ## The editing model Each open document holds a `pdfcraft_cos::Document`: the object graph plus a copy-on-write overlay. diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 8958688d2d..0497594e1e 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -113,7 +113,13 @@ pub struct JsRunner { impl JsRunner { pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner { let doc = pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos) }; - JsRunner { doc, doc_scripts: pdfcraft_forms::document_scripts(cos), output: JsOutput::default(), cos: cos.clone() } + // Once XFA owns the form, the PDF container's scripts belong to its fallback view. + // Keep them in the file and in Document JavaScripts, but do not initialize an + // AcroForm context with them: a viewer-compatibility alert would be misleading. + // Packets alone are not enough: static XFA and an unlaid-out shell keep their scripts. + let xfa_active = pdfcraft_xfa::xfa_shape(cos).is_some() && pdfcraft_xfa::existing_layout(cos).is_some(); + let doc_scripts = if xfa_active { Vec::new() } else { pdfcraft_forms::document_scripts(cos) }; + JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone() } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed90eea411..f8e898564e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3035,6 +3035,78 @@ fn form_javascript_validates_calculates_and_formats() { assert!(s.run_javascript(id, "1", None).is_err()); } +#[test] +fn active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts() { + let template = pdfcraft_xfa::fixtures::scripted_template().replace( + r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, + r#"xfa.host.messageBox("Hello " + qty.rawValue); xfa.host.print(1, "0", "0", 0, 0, 0, 0, 0);"#, + ); + let mut cos = pdfcraft_cos::Document::open(Arc::new(pdfcraft_xfa::fixtures::shell(&template))).unwrap(); + let fallback = "app.alert('This PDF form requires a newer version of Adobe Acrobat');"; + pdfcraft_forms::set_document_script(&mut cos, "viewerFallback", Some(fallback)).unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("xfa.pdf", None, bytes, None).unwrap(); + assert!(s.get(id).unwrap().xfa.is_some()); + assert!(s.take_js_output(id).alerts.is_empty()); + let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap(); + assert!(out.error.is_none(), "{out:?}"); + assert_eq!(out.alerts, ["Console message"]); + assert_eq!(s.get(id).unwrap().document_scripts(), [("viewerFallback".into(), fallback.into())]); + let out = s.run_javascript(id, "", Some("hello")).unwrap(); + assert!(out.error.is_none(), "{out:?}"); + assert_eq!(out.alerts, ["Hello 2"]); + assert_eq!(out.requests, [js::Request::Print]); + // The same context rule applies to format/validate/calculate through JsRunner. + s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "validate".into(), script: Some("app.alert('Field message');".into()) }).unwrap(); + s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap(); + assert_eq!(s.take_js_output(id).alerts, ["Field message"]); + // Saved XFA keeps its container scripts for inspection, but does not execute them. + let saved = s.get(id).unwrap().bytes.clone(); + let reopened = s.open("saved.pdf", None, saved, None).unwrap(); + assert!(s.run_javascript(reopened, "", None).unwrap().alerts.is_empty()); + // Making the form static restores the ordinary PDF context; undo restores XFA's. + s.apply(id, Edit::XfaToStatic).unwrap(); + assert_eq!(s.run_javascript(id, "", None).unwrap().alerts.len(), 1); + s.undo(id).unwrap(); + assert!(s.run_javascript(id, "", None).unwrap().alerts.is_empty()); +} + +#[test] +fn active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts() { + let template = r#""#; + let mut s = Session::new(); + let id = s.open("no-scripts.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(template)), None).unwrap(); + assert!(s.get(id).unwrap().xfa.is_some()); + assert!(s.get(id).unwrap().xfa_template.is_none()); + s.apply(id, Edit::SetDocumentScript { name: "fallback".into(), script: Some("throw new Error('PDF fallback ran');".into()) }).unwrap(); + let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap(); + assert!(out.error.is_none(), "{out:?}"); + assert_eq!(out.alerts, ["Console message"]); +} + +#[test] +fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() { + for bytes in [ + fixture(1), + pdfcraft_xfa::fixtures::static_shell("Test"), + pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()), + ] { + let mut cos = pdfcraft_cos::Document::open(Arc::new(bytes)).unwrap(); + pdfcraft_forms::set_document_script(&mut cos, "helper", Some("function twice(n) { return n * 2; } app.alert('PDF message');")).unwrap(); + // The raw dynamic shell has not been laid out: XFA's presence alone is not enough. + let runner = js::JsRunner::new(&cos, "control.pdf"); + let out = pdfcraft_js::run("twice(3)", &pdfcraft_js::Event::doc("Console"), &runner.doc, &[], &runner.doc_scripts, Default::default()); + assert!(out.error.is_none(), "{out:?}"); + assert_eq!(out.result.as_deref(), Some("6")); + assert_eq!(out.alerts, ["PDF message"]); + } +} + #[test] fn edit_history_does_not_replay_field_script_side_effects() { let mut s = Session::new().with_clock(|| 1_700_000_000); diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 5d494d7b44..0d4cdf9ed1 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -59,7 +59,10 @@ be measured exponentially often in their nesting. A measurement budget backs thi the datasets packet per event), and `rerender`, which lays the form out again from template, data and overrides. The scripts themselves run in the `js` crate's XFA object model; the engine runs initialize and calculate on open, change, exit, validate and calculate after a - field changes, and click for buttons. + field changes, and click for buttons. While this XFA layout is active, the engine does not + initialize Acrobat JavaScript with the PDF container's document-level scripts (including + non-XFA-viewer warnings). They stay available for inspection and saving; explicit console + scripts and XFA messages still run. Static XFA keeps its document scripts. Private keys PdfCraft writes (other viewers ignore them): diff --git a/docs/roadmap.md b/docs/roadmap.md index 3148823ea5..7a4b52c866 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2589: active XFA skips PDF-container script initialization; requested messages and static/ordinary PDF scripts remain; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -128,9 +129,9 @@ total matches the parity estimate. | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | -| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Active XFA skips PDF-container fallback scripts (#2589), preserving requested messages and saved scripts.
Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | -| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Active XFA skips PDF-container fallback scripts (#2589), preserving requested messages and saved scripts.
Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | @@ -179,6 +180,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M12 #2589: active XFA skips PDF-container script initialization; requested messages and static/ordinary PDF scripts remain; 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. | | 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. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 533013b547..cdce9fb60d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5220,8 +5220,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen"] +notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From 85e6d3307305c6514698e13b263ebfbf6782a2a5 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:43:06 +0300 Subject: [PATCH 02/47] M12: keep roadmap status blockquotes valid --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 27d5a87d31..56e2c90d7d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,7 @@ # PdfCraft roadmap: milestones and what's next > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2589: active XFA skips PDF-container script initialization; requested messages and static/ordinary PDF scripts remain; estimates unchanged) - +> > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) From 24932538211ff4f587a2ec497b3de99cbef77465 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:13:59 +0300 Subject: [PATCH 03/47] M12: reconcile current accessibility and XFA roadmap status --- docs/roadmap.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 56e2c90d7d..9dc6bb220c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -136,9 +136,8 @@ total matches the parity estimate. | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | -| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: Active XFA skips PDF-container fallback scripts (#2589), preserving requested messages and saved scripts.
Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool for one page; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Done since: Active XFA skips PDF-container fallback scripts (#2589), preserving requested messages and saved scripts.
Done since: Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | -| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Done since: Active XFA skips PDF-container fallback scripts (#2589), preserving requested messages and saved scripts.
Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | From c392d93ba6f864dd3e35e91176ba4aba29b5afa1 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:06:32 -0400 Subject: [PATCH 04/47] M12: laying out a dynamic XFA form takes off the usage rights it voids The layout revision replaces the placeholder pages and adds the fields, so usage rights (/Perms /UR3) signed over the form as published no longer match it, and a viewer may then open the form with every field locked. write_form now takes them off with the helper strip already needed, and the layout report says so. --- crates/engine/src/tests.rs | 26 +++++++++++++++ crates/ui-egui/tests/ui_group/xfa.rs | 8 +++++ crates/xfa/src/fixtures.rs | 21 ++++++++++++ crates/xfa/src/lib.rs | 3 ++ crates/xfa/src/pdf.rs | 50 ++++++++++++++++++---------- crates/xfa/src/script.rs | 3 ++ crates/xfa/src/tests.rs | 31 +++++++++++++++++ parity/acrobat-features.toml | 6 ++-- 8 files changed, 128 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41dac..c89c5aac7c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4071,6 +4071,32 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { ); } +#[test] +fn a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided() { + let xdp = pdfcraft_xfa::fixtures::template(3); + let original = Arc::new(pdfcraft_xfa::fixtures::usage_rights_shell(&xdp, "", false)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, original.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + let warnings = &doc.xfa.as_ref().expect("laid out from the template").warnings; + assert!(warnings.iter().any(|w| w.contains("usage rights")), "the user is told: {warnings:?}"); + assert!(!doc.dirty, "laying out is still not an edit"); + s.apply(id, Edit::SetFieldValue { name: "familyName".into(), value: FieldValue::Text("Singh".into()) }).unwrap(); + let incremental = s.save_bytes(id).unwrap(); + assert!(incremental.starts_with(&original[..]), "still an incremental save"); + // Saved either way, the file no longer carries them, and keeps what was filled in. + for saved in [incremental, s.save_full_bytes(id).unwrap()] { + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let catalog = cos.get(cos.root().unwrap()).as_dict().cloned().unwrap(); + assert!(!catalog.contains(b"Perms"), "{catalog:?}"); + let again = s.open("saved.pdf", None, saved, None).unwrap(); + let d2 = s.get(again).unwrap(); + assert_eq!(d2.form.iter().find(|f| f.name == "familyName").unwrap().value, vec!["Singh".to_string()]); + let warnings = &d2.xfa.as_ref().expect("the layout is remembered").warnings; + assert!(!warnings.iter().any(|w| w.contains("usage rights")), "nothing left to say: {warnings:?}"); + } +} + #[test] fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))); diff --git a/crates/ui-egui/tests/ui_group/xfa.rs b/crates/ui-egui/tests/ui_group/xfa.rs index 2285f36e00..327b2368d9 100644 --- a/crates/ui-egui/tests/ui_group/xfa.rs +++ b/crates/ui-egui/tests/ui_group/xfa.rs @@ -62,6 +62,14 @@ fn a_dynamic_xfa_form_is_laid_out_and_filled() { h.get_by_label("Highlight fields"); } +#[test] +fn a_dynamic_xfa_form_says_when_laying_it_out_took_its_usage_rights_off() { + let xdp = pdfcraft_xfa::fixtures::template(2); + let h = open(pdfcraft_xfa::fixtures::usage_rights_shell(&xdp, "", false)); + h.get_by_label_contains("laid out from its template: 2 pages, 11 fields"); + h.get_by_label_contains("usage rights were removed"); +} + #[test] fn a_dynamic_xfa_form_says_its_page_is_a_placeholder() { // No fields, the form lives in the XFA packets (an array of name/stream pairs here), but diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 54bb9bc216..e3ab415e66 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -364,6 +364,27 @@ pub fn shell(xdp: &str) -> Vec { assemble(objs) } +/// [`shell`] with usage rights: the catalog's `/Perms` holds a `/UR3` signature (a stand-in +/// dictionary, nothing verifies it), beside `others` (`/DocMDP 6 0 R`, say: object 6 is a +/// signature dictionary too). `indirect` puts `/Perms` in an object of its own. +pub fn usage_rights_shell(xdp: &str, others: &str, indirect: bool) -> Vec { + let perms = format!("<< /UR3 6 0 R {others} >>"); + let entry = if indirect { "7 0 R".to_string() } else { perms.clone() }; + let catalog = format!("<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> /Perms {entry} >>"); + let mut objs: Vec> = vec![ + catalog.into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".to_vec(), + b"<< /Length 44 >>\nstream\nBT /F1 12 Tf 72 700 Td (Please wait...) Tj ET\nendstream".to_vec(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(), + b"<< /Type /Sig /Reference [<< /Type /SigRef /TransformMethod /UR3 >>] >>".to_vec(), + ]; + if indirect { + objs.push(perms.into_bytes()); + } + assemble(objs) +} + /// A dynamic form with two signature boxes of which `sign` has been signed: the file holds the /// field a signing application leaves (named by its SOM path, its value a signature dictionary) /// and the picture drawn in the box, a solid blue rectangle. diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index bf4414970e..8711df9fee 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -165,6 +165,9 @@ pub fn render_into(doc: &mut Document) -> Result { let written = pdf::write_form(doc, &form)?; let mut warnings = form.warnings; warnings.extend(written.warnings); + if written.usage_rights { + warnings.push(crate::pdf::USAGE_RIGHTS_REMOVED.into()); + } warnings.dedup(); Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings }) } diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index cca62e64e7..a84ed9c421 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -150,10 +150,16 @@ fn js_action(js: &str) -> Object { Object::Dict(a) } +/// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). +pub(crate) const USAGE_RIGHTS_REMOVED: &str = + "the form's usage rights were removed: laying it out voids them, and a form with void usage rights can open with every field locked"; + pub struct Written { pub pages: usize, pub fields: usize, pub warnings: Vec, + /// Usage rights were taken off the catalog (laying the form out voided them). + pub usage_rights: bool, } /// On a push button generated for an XFA image field: clicking it picks a picture (the forms @@ -774,10 +780,12 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result catalog_changed = true; } } - if catalog_changed { + // The pages and fields are new: usage rights signed over the form as it was are void now. + let usage_rights = drop_usage_rights(doc, &mut catalog); + if catalog_changed || usage_rights { doc.set(catalog_ref, Object::Dict(catalog)); } - Ok(Written { pages: count, fields: field_count, warnings }) + Ok(Written { pages: count, fields: field_count, warnings, usage_rights }) } /// What [`strip`] took out, for the user to be told (the packets held more than the fields). @@ -963,21 +971,7 @@ pub fn strip(doc: &mut Document) -> Result { None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)), } catalog.remove(b"NeedsRendering"); - // Reader usage rights are void once the form changed: Reader says so on opening the file - // and drops them, so they go now, with a note. - if let Some(perms) = catalog.get(b"Perms").map(|p| doc.resolve(p)) - && let Some(mut perms) = perms.as_dict().cloned() - && (perms.contains(b"UR3") || perms.contains(b"UR")) - { - perms.remove(b"UR3"); - perms.remove(b"UR"); - out.usage_rights = true; - if perms.is_empty() { - catalog.remove(b"Perms"); - } else { - catalog.set(b"Perms".to_vec(), Object::Dict(perms)); - } - } + out.usage_rights = drop_usage_rights(doc, &mut catalog); doc.set(root, Object::Dict(catalog)); // The packet streams are referenced from nowhere else: freed, so an incremental save // doesn't carry an unreachable copy of the data. @@ -1024,6 +1018,28 @@ fn unbound_data(doc: &Document) -> Option { Some(count) } +/// Take the usage rights (`/Perms /UR3`, or the older `/UR`) off a catalog, keeping the rest of +/// `/Perms` (a certification's `/DocMDP`). They are signed over the form as it was: once its +/// pages or fields change they are void, and a viewer may lock every field of a form whose +/// usage rights are void, so they go, and the caller says so. Whether there were any. +fn drop_usage_rights(doc: &mut Document, catalog: &mut Dict) -> bool { + let Some(entry) = catalog.get(b"Perms").cloned() else { return false }; + let Some(mut perms) = doc.resolve(&entry).as_dict().cloned() else { return false }; + if !perms.contains(b"UR3") && !perms.contains(b"UR") { + return false; + } + perms.remove(b"UR3"); + perms.remove(b"UR"); + match entry.as_ref() { + _ if perms.is_empty() => { + catalog.remove(b"Perms"); + } + Some(r) => doc.set(r, Object::Dict(perms)), + None => catalog.set(b"Perms".to_vec(), Object::Dict(perms)), + } + true +} + /// The layout a document already carries from an earlier pass, if any. pub fn existing_layout(doc: &Document) -> Option { let root = doc.root()?; diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 17a0376641..a3ac812eba 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -576,6 +576,9 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result let written = crate::pdf::write_form(doc, &form)?; let mut warnings = form.warnings; warnings.extend(written.warnings); + if written.usage_rights { + warnings.push(crate::pdf::USAGE_RIGHTS_REMOVED.into()); + } warnings.dedup(); Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings }) } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 03eb2c67ad..3dd84222c2 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1961,6 +1961,37 @@ fn an_anchor_type_places_the_named_point_of_the_box_at_its_x_and_y() { assert!((field.rect.x - 6.0 * 72.0).abs() < 0.01 && (field.rect.y - 72.0).abs() < 0.01, "{:?}", field.rect); } +#[test] +fn laying_a_form_out_takes_off_the_usage_rights_it_voids() { + use crate::fixtures::usage_rights_shell; + let xdp = fields_template("
JP
"); + // What `/Perms` holds once the layout is saved and the file read again. + let laid_out = |bytes: Vec| { + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + let report = render_into(&mut doc).unwrap(); + let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let indirect = catalog.get(b"Perms").and_then(Object::as_ref).is_some(); + let perms = catalog.get(b"Perms").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()); + let said = report.warnings.iter().any(|w| w.contains("usage rights")); + (perms, indirect, said, report.warnings) + }; + // Usage rights alone: `/Perms` goes, and the user is told. + let (perms, _, said, warnings) = laid_out(usage_rights_shell(&xdp, "", false)); + assert!(perms.is_none(), "{perms:?}"); + assert!(said, "{warnings:?}"); + // Beside a certification, in an object of its own: only the usage rights go, and the + // object is rewritten where it is. + let (perms, indirect, said, warnings) = laid_out(usage_rights_shell(&xdp, "/DocMDP 6 0 R", true)); + let perms = perms.expect("the certification stays"); + assert!(perms.contains(b"DocMDP") && !perms.contains(b"UR3"), "{perms:?}"); + assert!(indirect, "/Perms is still its own object"); + assert!(said, "{warnings:?}"); + // A form without usage rights says nothing about them. + let (perms, _, said, warnings) = laid_out(shell(&xdp)); + assert!(perms.is_none() && !said, "{perms:?} {warnings:?}"); +} + #[test] fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { use crate::pdf::strip; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b1805d5223..5f9fc9b9d5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -707,7 +707,7 @@ status = "planned" commands = [] tools = [] evidence = [] -notes = "preserve only" +notes = "preserve only. A dynamic XFA form PdfCraft lays out loses them: the layout voids them, so they are taken off with a note (form.xfa-dynamic)" # ================================================================================================== # B. Viewing and navigation @@ -5220,8 +5220,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided"] +notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From 93544ef1bff769b2b722d43bbdc024e4484304b5 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:32:52 -0400 Subject: [PATCH 05/47] M12: the XFA notice bar starts its first layout note as a sentence Layout notes are lowercase clauses joined with semicolons, and the bar put the first right after the layout's own sentence ('11 fields. the form's usage rights...'). The first note now starts with a capital. --- crates/ui-egui/src/canvas.rs | 6 +++++- crates/ui-egui/tests/ui_group/xfa.rs | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ad74834656..1a412ff635 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4494,8 +4494,12 @@ fn notices( let mut text = crate::i18n::fmt(tl!("Dynamic XFA form laid out from its template: {pages}, {fields}."), &[("pages", &pages), ("fields", &fields)]); if !x.warnings.is_empty() { + // The notes are clauses ("a picture is left out"); the first starts a sentence here. + let notes = x.warnings.join("; "); + let mut chars = notes.chars(); text.push(' '); - text.push_str(&x.warnings.join("; ")); + text.extend(chars.next().into_iter().flat_map(char::to_uppercase)); + text.push_str(chars.as_str()); text.push('.'); } Some(("text-cursor-input", text, true)) diff --git a/crates/ui-egui/tests/ui_group/xfa.rs b/crates/ui-egui/tests/ui_group/xfa.rs index 327b2368d9..34bc52386c 100644 --- a/crates/ui-egui/tests/ui_group/xfa.rs +++ b/crates/ui-egui/tests/ui_group/xfa.rs @@ -66,8 +66,8 @@ fn a_dynamic_xfa_form_is_laid_out_and_filled() { fn a_dynamic_xfa_form_says_when_laying_it_out_took_its_usage_rights_off() { let xdp = pdfcraft_xfa::fixtures::template(2); let h = open(pdfcraft_xfa::fixtures::usage_rights_shell(&xdp, "", false)); - h.get_by_label_contains("laid out from its template: 2 pages, 11 fields"); - h.get_by_label_contains("usage rights were removed"); + // The note starts its own sentence after the layout's. + h.get_by_label_contains("laid out from its template: 2 pages, 11 fields. The form's usage rights were removed"); } #[test] From 86e44dc6d53cd42d9159ba690a8fca7820ed8efe Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:33:17 -0400 Subject: [PATCH 06/47] M6: util.scand reads dates as another viewer does --- crates/js/src/formcalc.rs | 2 +- crates/js/src/lib.rs | 317 ++++++++++++++++++++++++++++++++++- crates/js/src/tests.rs | 179 ++++++++++++++++++++ crates/js/src/xfa.rs | 3 +- parity/acrobat-features.toml | 4 +- 5 files changed, 499 insertions(+), 6 deletions(-) diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index dc32d457bf..43f32a11b0 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -1924,7 +1924,7 @@ fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { } /// Number of days in month `m` (1-12) of year `y`, leap years included. -fn days_in(y: i64, m: u32) -> u32 { +pub(crate) fn days_in(y: i64, m: u32) -> u32 { match m { 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, 2 => 28, diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index d8d9fa8bac..0e7de9891f 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -14,7 +14,7 @@ //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, //! `setFocus`. //! - `app` (`alert`, `beep`, `response`, `launchURL`, `viewerType`, `viewerVersion`, -//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`), `console` (`println`, +//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`, `scand`), `console` (`println`, //! `show`, `clear`), `display` and `color`. //! //! The engine is a sandbox: no file, network or timer access exists, and loops and recursion @@ -26,7 +26,7 @@ use std::cell::RefCell; use std::collections::BTreeSet; -use boa_engine::object::builtins::{JsArray, JsFunction}; +use boa_engine::object::builtins::{JsArray, JsDate, JsFunction}; use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer}; use boa_engine::property::Attribute; use boa_engine::{Context, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; @@ -802,6 +802,318 @@ fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Ok(s(&out)) } +/// A field of a `util.scand` format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DateUnit { + /// `yyyy`: four digits. + Year, + /// `yy`: two digits, read as 1950–2049. + ShortYear, + Month, + /// `mmm`, `mmmm`: a month's name or its three-letter abbreviation. + MonthName, + Day, + /// `ddd`, `dddd`: a weekday's name, read and not checked. + Weekday, + Hour, + /// `h`, `hh`: 0–12, with `tt` for the afternoon. + Hour12, + Minute, + Second, + /// `t`, `tt`: am or pm. + AmPm, +} + +impl DateUnit { + fn is_date(self) -> bool { + matches!(self, Self::Year | Self::ShortYear | Self::Month | Self::MonthName | Self::Day) + } + + /// Read from a run of digits, not from letters. + fn is_numeric(self) -> bool { + !matches!(self, Self::MonthName | Self::Weekday | Self::AmPm) + } +} + +/// A piece of a `util.scand` format. +#[derive(Debug, PartialEq, Eq)] +enum ScanPart { + /// A field, the digits it takes when typed together with the next one (`width`), and whether + /// the next one follows it with nothing between them in the format (`joined`). + Field { unit: DateUnit, width: usize, joined: bool }, + /// Letters the format writes as they are, which the text must have there. + Word(String), +} + +fn scan_parts(fmt: &str) -> Vec { + let chars: Vec = fmt.chars().collect(); + let mut parts: Vec = Vec::new(); + // The last part is a field written right before this point, or letters still being collected. + let (mut after_field, mut in_word) = (false, false); + let mut i = 0; + while let Some(&c) = chars.get(i) { + // Counted for field letters only, so a long run of anything else is read once. + let run = if "ymdHhMst".contains(c) { chars.get(i..).unwrap_or_default().iter().take_while(|x| **x == c).count() } else { 1 }; + let field = match (c, run) { + ('y', 4..) => Some((DateUnit::Year, 4)), + ('y', _) => Some((DateUnit::ShortYear, 2)), + ('m', 3..) => Some((DateUnit::MonthName, 0)), + ('m', n) => Some((DateUnit::Month, n)), + ('d', 3..) => Some((DateUnit::Weekday, 0)), + ('d', n) => Some((DateUnit::Day, n)), + ('H', n) => Some((DateUnit::Hour, n.min(2))), + ('h', n) => Some((DateUnit::Hour12, n.min(2))), + ('M', n) => Some((DateUnit::Minute, n.min(2))), + ('s', n) => Some((DateUnit::Second, n.min(2))), + ('t', _) => Some((DateUnit::AmPm, 0)), + _ => None, + }; + if let Some((unit, width)) = field { + if after_field + && unit.is_numeric() + && let Some(ScanPart::Field { unit: last, joined, .. }) = parts.last_mut() + && last.is_numeric() + { + *joined = true; + } + parts.push(ScanPart::Field { unit, width, joined: false }); + (after_field, in_word) = (true, false); + i += run; + continue; + } + // `\` writes the next character as it is. + let (literal, step) = if c == '\\' { (chars.get(i + 1).copied(), 2) } else { (Some(c), 1) }; + i += step; + after_field = false; + match literal { + Some(l) if l.is_alphanumeric() => match parts.last_mut() { + Some(ScanPart::Word(w)) if in_word => w.push(l), + _ => { + parts.push(ScanPart::Word(l.to_string())); + in_word = true; + } + }, + _ => in_word = false, + } + } + parts +} + +/// A run of digits or of letters in the text `util.scand` reads. +enum ScanToken<'a> { + Digits(&'a str), + Letters(&'a str), +} + +/// The text's runs of digits and of letters, in order; anything else only separates them, and +/// may not begin or end the text. `None` when it does. +fn scan_tokens(text: &str) -> Option>> { + // Some(true) for a digit, Some(false) for a letter, None for a separator. + let kind = |c: char| (c.is_ascii_digit() || c.is_alphabetic()).then_some(c.is_ascii_digit()); + kind(text.chars().next()?)?; + kind(text.chars().next_back()?)?; + let mut out = Vec::new(); + let mut start: Option<(usize, bool)> = None; + for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { + let k = kind(c); + if let Some((from, digits)) = start + && k != Some(digits) + { + let piece = text.get(from..at)?; + out.push(if digits { ScanToken::Digits(piece) } else { ScanToken::Letters(piece) }); + start = None; + } + if let Some(digits) = k + && start.is_none() + { + start = Some((at, digits)); + } + } + Some(out) +} + +/// What `util.scand` reads. +#[derive(Debug, PartialEq, Eq)] +enum Scanned { + /// The text is empty: today, at noon. + Today, + /// A date and time: year, month (1–12), day, hours, minutes, seconds. + At(i32, u32, u32, u32, u32, u32), +} + +/// The fields `util.scand` has read so far. +#[derive(Default)] +struct ScanValues { + y: Option, + mo: Option, + d: Option, + h: Option, + mi: Option, + sec: Option, + pm: Option, + /// The hour was read from `h` or `hh`. + twelve: bool, +} + +impl ScanValues { + fn set(&mut self, unit: DateUnit, v: u32) { + match unit { + DateUnit::Year => self.y = i32::try_from(v).ok(), + DateUnit::ShortYear => self.y = i32::try_from(v).ok().map(|v| if v < 50 { 2000 + v } else { 1900 + v }), + DateUnit::Month => self.mo = Some(v), + DateUnit::Day => self.d = Some(v), + DateUnit::Hour => self.h = Some(v), + DateUnit::Hour12 => { + self.h = Some(v); + self.twelve = true; + } + DateUnit::Minute => self.mi = Some(v), + DateUnit::Second => self.sec = Some(v), + DateUnit::MonthName | DateUnit::Weekday | DateUnit::AmPm => {} + } + } +} + +/// Reads `text` by a `util.scand` format, as another viewer was observed to (black-box): +/// +/// - Fields may be separated by anything but digits and letters, whether or not the format +/// separates them, but nothing may come before the first or after the last. +/// - Digits typed together fill the format's fields written together with exactly as many +/// digits as each has letters (`yyyymd`: 4, 1 and 1). A run of digits for one field alone may +/// have one or two, except a four-digit year. +/// - `yy` is 1950–2049; month names may be written in full or as three letters, in any case. +/// - Impossible dates are refused, but a month or day of 0 is read as 1. +/// - A format with no year, month or day reads nothing; an empty text is today, at noon. +/// +/// Not observed: a year left out is `this_year`, and a month or day left out is 1. +fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { + let parts = scan_parts(fmt); + if !parts.iter().any(|p| matches!(p, ScanPart::Field { unit, .. } if unit.is_date())) { + return None; + } + if text.is_empty() { + return Some(Scanned::Today); + } + let tokens = scan_tokens(text)?; + let mut v = ScanValues::default(); + let (mut p, mut t) = (0, 0); + while let Some(part) = parts.get(p) { + let token = tokens.get(t)?; + t += 1; + match (part, token) { + (ScanPart::Word(w), ScanToken::Letters(l) | ScanToken::Digits(l)) => { + if w.to_lowercase() != l.to_lowercase() { + return None; + } + p += 1; + } + (ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => { + v.mo = Some(named(&MONTHS, l)?.saturating_add(1)); + p += 1; + } + (ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => { + named(&DAYS, l)?; + p += 1; + } + (ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => { + v.pm = Some(match l.to_lowercase().as_str() { + "am" | "a" => false, + "pm" | "p" => true, + _ => return None, + }); + p += 1; + } + (ScanPart::Field { unit, width, joined }, ScanToken::Digits(run)) if unit.is_numeric() => { + // The fields written together from here, no more than the run's digits can fill, + // and the fewest of them the run fills. + let mut group = vec![(*unit, *width)]; + let (mut more, mut widths) = (*joined, *width); + while more + && widths < run.len() + && let Some(ScanPart::Field { unit, width, joined }) = parts.get(p + group.len()) + { + group.push((*unit, *width)); + widths = widths.saturating_add(*width); + more = *joined; + } + let mut sum = 0; + let fills = group.iter().position(|(_, w)| { + sum += w; + sum == run.len() + }); + if let Some(last) = fills { + let mut at = 0; + for (unit, w) in group.iter().take(last + 1) { + v.set(*unit, run.get(at..at + w)?.parse().ok()?); + at += w; + } + p += last + 1; + } else if *unit != DateUnit::Year && run.len() <= 2 { + v.set(*unit, run.parse().ok()?); + p += 1; + } else { + return None; + } + } + _ => return None, + } + } + if t != tokens.len() { + return None; + } + let y = v.y.unwrap_or(this_year); + let mo = v.mo.unwrap_or(1).max(1); + let d = v.d.unwrap_or(1).max(1); + if mo > 12 || i64::from(d) > i64::from(formcalc::days_in(i64::from(y), mo)) { + return None; + } + let mut h = v.h.unwrap_or(0); + if v.twelve && h > 12 { + return None; + } + match v.pm { + Some(true) if h < 12 => h += 12, + Some(false) if h == 12 => h = 0, + _ => {} + } + let (mi, sec) = (v.mi.unwrap_or(0), v.sec.unwrap_or(0)); + (h <= 23 && mi <= 59 && sec <= 59).then_some(Scanned::At(y, mo, d, h, mi, sec)) +} + +/// The index of the name `word` is, in full or as its first three letters, in any case. +fn named(names: &[&str], word: &str) -> Option { + let word = word.to_lowercase(); + let i = names.iter().position(|n| { + let n = n.to_lowercase(); + word == n || n.get(..3) == Some(word.as_str()) + })?; + u32::try_from(i).ok() +} + +/// `util.scand(cFormat, cDate)`: the date `cDate` gives by a `util.printd` format, as a local +/// `Date`, or `null` when it doesn't read as one (see [`scan_date`]). Only text is read. +fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let Some(typed) = arg(args, 1).as_string().map(|t| t.to_std_string_escaped()) else { + return Ok(JsValue::null()); + }; + let fmt = text(&arg(args, 0), ctx)?; + let date = JsDate::new(ctx); + let this_year = date.get_full_year(ctx)?.to_number(ctx)?; + let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 }; + match scan_date(&fmt, &typed, this_year) { + None => return Ok(JsValue::null()), + Some(Scanned::Today) => { + date.set_hours(&[12.into(), 0.into(), 0.into(), 0.into()], ctx)?; + } + Some(Scanned::At(y, mo, d, h, mi, sec)) => { + // setFullYear, not the constructor, so years 0–99 stay as they are. + date.set_full_year(&[y.into(), (mo.saturating_sub(1)).into(), d.into()], ctx)?; + date.set_hours(&[h.into(), mi.into(), sec.into(), 0.into()], ctx)?; + } + } + Ok(date.into()) +} + /// `util.printx(cFormat, cSource)`: `?` any character, `X` letter or digit, `A` letter, `9` /// digit, `*` the rest, `\` escape, `>` / `<` / `=` upper / lower / as is. fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { @@ -947,6 +1259,7 @@ fn install(ctx: &mut Context) -> JsResult<()> { .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) .build(); ctx.register_global_property(js_string!("util"), util, rw)?; let console = ObjectInitializer::new(ctx) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 61d3a71e51..791da93a24 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1132,3 +1132,182 @@ mod session { assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}"); } } + +mod scand { + use super::*; + use crate::xfa::{XfaDoc, XfaEvent, run_xfa}; + + /// Runs `util.scand(format, text)` for each case and checks the date it gives, written as + /// `yyyy-mm-dd HH:MM:ss`, or `null`. + fn assert_scand(cases: &[(&str, &str, &str)]) { + let list: Vec = cases.iter().map(|(f, t, _)| format!("['{f}', '{t}']")).collect(); + let script = format!( + "event.value = [{}].map(function (c) {{ var d = util.scand(c[0], c[1]); \ + return d === null ? 'null' : util.printd('yyyy-mm-dd HH:MM:ss', d); }}).join('|');", + list.join(", ") + ); + let o = go(&script, &Event::field("Calculate", "total", "")); + assert_eq!(o.error, None); + let got: Vec<&str> = o.value.split('|').collect(); + assert_eq!(got.len(), cases.len()); + for ((f, t, want), got) in cases.iter().zip(got) { + assert_eq!(got, *want, "util.scand('{f}', '{t}')"); + } + } + + // The cases below are another viewer's answers, observed black-box with a synthetic file. + + #[test] + fn scand_allows_separators_between_fields_but_nothing_before_or_after_them() { + assert_scand(&[ + ("yyyymmdd", "20261018", "2026-10-18 00:00:00"), + ("yyyymmdd", "2026-10-18", "2026-10-18 00:00:00"), + ("yyyymmdd", "2026 10 18", "2026-10-18 00:00:00"), + ("mm/dd/yyyy", "10/18/2026", "2026-10-18 00:00:00"), + ("mm/dd/yyyy", "10-18-2026", "2026-10-18 00:00:00"), + ("yyyymmdd", " 20261018 ", "null"), + ("yyyymmdd", "20261018x", "null"), + ("yyyymmdd", "x20261018", "null"), + ]); + } + + #[test] + fn scand_splits_digits_typed_together_by_the_formats_letter_counts() { + assert_scand(&[ + ("yyyymmdd", "261018", "null"), + ("yyyymmdd", "2026118", "null"), + ("yyyymmdd", "202610181", "null"), + ("yymmdd", "20261018", "null"), + ("yyyymdd", "2026118", "2026-01-18 00:00:00"), + ("yyyymmd", "2026118", "2026-11-08 00:00:00"), + ("yyyymd", "2026118", "null"), + ("yyyymd", "202618", "2026-01-08 00:00:00"), + ("yyyymd", "20261018", "null"), + ("yymd", "2618", "2026-01-08 00:00:00"), + ("yymd", "261018", "null"), + ]); + } + + #[test] + fn scand_refuses_impossible_dates_and_reads_a_zero_month_or_day_as_1() { + assert_scand(&[ + ("yyyymmdd", "20261318", "null"), + ("yyyymmdd", "20260230", "null"), + ("yyyymmdd", "20260229", "null"), + ("yyyymmdd", "20240229", "2024-02-29 00:00:00"), + ("mm/dd/yyyy", "13/45/2026", "null"), + ("yyyymmdd", "20260018", "2026-01-18 00:00:00"), + ("yyyymmdd", "20261000", "2026-10-01 00:00:00"), + ]); + } + + #[test] + fn scand_reads_lone_fields_two_digit_years_and_month_and_weekday_names() { + assert_scand(&[ + ("mm/dd/yyyy", "1/5/2026", "2026-01-05 00:00:00"), + ("m/d/yyyy", "1/5/2026", "2026-01-05 00:00:00"), + ("m/d/yyyy", "01/05/2026", "2026-01-05 00:00:00"), + ("m/d/yy", "1/5/26", "2026-01-05 00:00:00"), + ("mm/dd/yyyy", "10/18/26", "null"), + ("yyyy/mm/dd", "26/10/18", "null"), + ("yymmdd", "261018", "2026-10-18 00:00:00"), + ("yymmdd", "491018", "2049-10-18 00:00:00"), + ("yymmdd", "501018", "1950-10-18 00:00:00"), + ("mmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"), + ("mmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"), + ("mmm d, yyyy", "oct 18, 2026", "2026-10-18 00:00:00"), + ("mmmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"), + ("mmmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"), + ("dd-mmm-yyyy", "18-Oct-2026", "2026-10-18 00:00:00"), + ("dddd, mmmm d, yyyy", "Sunday, October 18, 2026", "2026-10-18 00:00:00"), + ]); + } + + #[test] + fn scand_reads_a_time_only_with_a_date() { + assert_scand(&[ + ("mm/dd/yyyy HH:MM:ss", "10/18/2026 21:07:05", "2026-10-18 21:07:05"), + ("HH:MM", "09:07", "null"), + ("HH:MM", "9:07", "null"), + ("h:MM tt", "9:07 pm", "null"), + ("h:MM tt", "9:07 am", "null"), + ("h:MM tt", "12:07 am", "null"), + ("h:MM tt", "9:07pm", "null"), + ]); + } + + #[test] + fn scand_reads_only_text_and_an_empty_text_is_today_at_noon() { + let o = go( + "var d = util.scand('yyyymmdd', ''), n = new Date(); \ + event.value = [d.toDateString() == n.toDateString(), d.getHours(), d.getMinutes(), d.getSeconds(), \ + util.scand('yyyymmdd', 20261018), util.scand(), util.scand('yyyymmdd')].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|12|0|0|||"); + } + + // Not observed: the choices made where the other viewer wasn't asked. + + #[test] + fn scand_takes_this_year_when_none_is_given_and_reads_12_hour_times_and_written_letters() { + assert_scand(&[ + ("mm/dd/yyyy h:MM tt", "10/18/2026 9:07 pm", "2026-10-18 21:07:00"), + ("mm/dd/yyyy h:MM tt", "10/18/2026 12:07 AM", "2026-10-18 00:07:00"), + ("mm/dd/yyyy h:MM tt", "10/18/2026 13:07 pm", "null"), + ("mm/dd/yyyy HH:MM", "10/18/2026 24:00", "null"), + ("yyyy-mm-ddTHH:MM", "2026-10-18T21:07", "2026-10-18 21:07:00"), + ("yyyy-mm-ddTHH:MM", "2026-10-18X21:07", "null"), + ]); + let o = go( + "var d = util.scand('mm/dd', '10/18'); event.value = [d.getFullYear() == new Date().getFullYear(), d.getMonth(), d.getDate()].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|9|18"); + } + + #[test] + fn scand_reads_long_formats_and_texts_in_linear_time() { + // Scripts can pass anything; native code isn't bounded by the loop limit. + let started = std::time::Instant::now(); + let o = go( + "var x = new Array(50001).join('x'), md = new Array(50001).join('md'), ones = new Array(100000).join('1 ') + '1'; \ + event.value = [util.scand(x + 'yyyy', x + '2026') !== null, util.scand('yyyy' + md, '2026 ' + ones) !== null].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|true"); + assert!(started.elapsed() < std::time::Duration::from_secs(2), "{:?}", started.elapsed()); + } + + #[test] + fn xfa_scripts_check_typed_dates_with_util_scand() { + // The check a form runs when a date field is left: the first of six formats that reads + // the typed text, written back as yyyymmdd; or an invalid date. Results as another viewer gives them. + let check = |typed: &str| { + let script = format!( + "var six = ['yyyymmdd', 'yymmdd', 'yyyymdd', 'yyyymmd', 'yyyymd', 'yymd'], out = 'invalid'; \ + for (var i = 0; i < six.length; i++) {{ var d = util.scand(six[i], '{typed}'); \ + if (d !== null) {{ out = util.printd('yyyymmdd', d); break; }} }} out" + ); + let event = XfaEvent { activity: "exit".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + run_xfa(&script, &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default()) + }; + for (typed, want) in [ + ("20261018", "20261018"), + ("261018", "20261018"), + ("2026118", "20260118"), + ("202618", "20260108"), + ("2618", "20260108"), + ("2026-10-18", "20261018"), + ("20261318", "invalid"), + ("20260230", "invalid"), + ] { + let o = check(typed); + assert_eq!(o.error, None, "{typed}: {o:?}"); + assert_eq!(o.result.as_deref(), Some(want), "{typed}"); + } + } +} diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index a2361da7e4..9a48ff16ab 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -15,7 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy}; use boa_engine::property::Attribute; use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; -use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, text}; +use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; /// What kind of template object a node is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1208,6 +1208,7 @@ fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) .build(); ctx.register_global_property(js_string!("util"), util, rw)?; // Unqualified names: the current node's children, its siblings, then each ancestor's diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8b..b8c0c9f368 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5136,8 +5136,8 @@ milestone = "M6" status = "partial" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet." +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"] +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; no annotations, layers or dialogs (app.response returns null) yet." [[feature]] id = "form.js-console" From 3a5bcbdf97663569fa4bf0e80202d7e1ecf101c2 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:21:08 -0400 Subject: [PATCH 07/47] M12: a date typed by another of an XFA field's edit patterns is kept in its first --- crates/engine/src/lib.rs | 18 +++++++++++---- crates/engine/src/tests.rs | 20 ++++++++++++++++ crates/engine/src/xfa.rs | 22 ++++++++++++++++++ crates/xfa/src/fixtures.rs | 18 +++++++++++++++ crates/xfa/src/layout.rs | 2 +- crates/xfa/src/lib.rs | 2 ++ crates/xfa/src/picture.rs | 47 ++++++++++++++++++++++++++++++++++++++ crates/xfa/src/tests.rs | 15 ++++++++++++ 8 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 crates/xfa/src/picture.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dfe11b25c6..3b21c952e1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2428,10 +2428,20 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::SetAnnotationInfo { page, index, author, subject, icon } => { pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; } - Edit::SetFieldValue { name, value } => match cx.js.as_mut() { - Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js)?, - None => pdfcraft_forms::set_value(doc, name, value)?, - }, + Edit::SetFieldValue { name, value } => { + let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { + Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), + None => pdfcraft_forms::set_value(doc, name, value), + }; + if let Err(refused) = set(doc, value) { + // A date the field's pattern doesn't read may be typed by another of an XFA date + // field's patterns: it is kept in the first. + match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { + Some(typed) => set(doc, &typed)?, + None => return Err(refused.into()), + } + } + } Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9365e744cb..8d4745add9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6153,3 +6153,23 @@ fn undo_never_makes_an_unsaved_document_clean() { s.undo(new).unwrap(); assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); } + +#[test] +fn a_date_typed_by_another_of_an_xfa_fields_edit_patterns_is_kept_in_its_first() { + let mut s = Session::new(); + let id = s + .open("dates.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::date_alternatives_template())), None) + .expect("opens"); + let value = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "leave").unwrap().value.clone(); + s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("261018".into()) }).expect("yymmdd is one of its patterns"); + assert_eq!(value(&s), ["20261018"]); + s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2026118".into()) }).expect("so is yyyymd"); + assert_eq!(value(&s), ["20261108"]); + let refused = s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2618".into()) }); + assert!(refused.is_err_and(|e| e.to_string().contains("yyyymmdd")), "none of its patterns reads it"); + assert_eq!(value(&s), ["20261108"]); + // The data holds the date. + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).expect("a datasets packet"); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].leave[0]")), Some("2026-11-08")); +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 0f37933afd..8f8e0a3871 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -810,6 +810,28 @@ pub(crate) fn on_data_replaced(doc: &mut pdfcraft_cos::Document, tpl: &Template, r.finish().map(|_| ()) } +/// A text value typed into the laid-out XFA date field `name` by another of its edit picture's +/// patterns, written in the first ([`pdfcraft_xfa::typed_date`]); `None` for anything else. +/// `tpl` is the template the scripts use, when they run; otherwise it is read from `doc`. +pub(crate) fn typed_date( + doc: &pdfcraft_cos::Document, + tpl: Option<&Template>, + name: &str, + value: &pdfcraft_forms::FieldValue, +) -> Option { + let pdfcraft_forms::FieldValue::Text(text) = value else { return None }; + let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find_map(|(som, n)| (n == name).then_some(som))?; + let read; + let tpl = match tpl { + Some(t) => t, + None => { + read = pdfcraft_xfa::template_of(doc).ok()?; + &read + } + }; + pdfcraft_xfa::typed_date(tpl, &som, text).map(pdfcraft_forms::FieldValue::Text) +} + /// A button's click script (or any event, by SOM path and activity), then calculations. pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<(), String> { let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS); diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 33dbb017e5..b7ae134820 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -375,6 +375,24 @@ pub fn fields_template(data: &str) -> String { ) } +/// Date fields whose edit pictures list alternatives, as forms let a date be typed several ways +/// (`date{YYYYMMDD}|date{YYMMDD}|…`); one with a single pattern; a text field. +pub fn date_alternatives_template() -> String { + r##" + + + +"## + .to_string() +} + /// A form of `sections` sections of `rows` rows, each row with a calculated total (half in /// JavaScript, half in FormCalc) and a section sum over its rows: 2 × sections × rows + sections /// calculate scripts, every one reading values the earlier ones settled. diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index b7c2605a6e..f6f4ab18d7 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -395,7 +395,7 @@ fn is_row(node: &Node) -> bool { } /// The Acrobat date pattern for an XFA picture clause such as `date{YYYY-MM-DD}`. -fn date_pattern(picture: Option<&str>) -> String { +pub(crate) fn date_pattern(picture: Option<&str>) -> String { picture.and_then(date_letters).unwrap_or_else(|| "yyyy-mm-dd".into()) } diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index bf4414970e..d6f4837be3 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -15,6 +15,7 @@ pub mod model; pub mod packets; pub mod parse; pub mod pdf; +pub mod picture; pub mod script; pub mod text; @@ -99,6 +100,7 @@ pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, Widge pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; pub use parse::{measure, parse}; pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape}; +pub use picture::typed_date; pub use script::{ FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, fields_by_som, form_tree, has_scripts, overrides, refresh_value, rerender, set_overrides, template_of, diff --git a/crates/xfa/src/picture.rs b/crates/xfa/src/picture.rs new file mode 100644 index 0000000000..58bd48cd69 --- /dev/null +++ b/crates/xfa/src/picture.rs @@ -0,0 +1,47 @@ +//! Picture clauses that list alternatives (`date{YYYYMMDD}|date{YYMMDD}`): a date may be typed +//! by any of them, and is shown, and kept in the field, by the first. + +use crate::data::{date_letters, iso_to_pattern, pattern_to_iso, template_field}; +use crate::layout::date_pattern; +use crate::model::{Template, Ui}; + +/// The alternatives of a picture clause: its parts between `|`s that aren't inside braces or +/// quotes. +pub(crate) fn alternatives(picture: &str) -> Vec<&str> { + let mut out = Vec::new(); + let (mut depth, mut quoted, mut start) = (0usize, false, 0); + for (i, c) in picture.char_indices() { + match c { + '\'' => quoted = !quoted, + '{' if !quoted => depth = depth.saturating_add(1), + '}' if !quoted => depth = depth.saturating_sub(1), + '|' if !quoted && depth == 0 => { + out.extend(picture.get(start..i)); + start = i + 1; + } + _ => {} + } + } + out.extend(picture.get(start..)); + out +} + +/// `text` typed into the date field at `som` by one of its edit picture's alternatives after the +/// first, written in the first, which the field shows and checks typed dates against (`261018` +/// becomes `20261018` for `date{YYYYMMDD}|date{YYMMDD}`). A pattern reads the text only when the +/// date it gives, written in that pattern, is the text again. `None` when the field isn't a date +/// field with alternatives, the first pattern reads the text, or no other does. +pub fn typed_date(tpl: &Template, som: &str, text: &str) -> Option { + let f = template_field(tpl, som)?; + if f.ui != Ui::DateTimeEdit { + return None; + } + let picture = f.picture.as_deref()?; + let reads = |pattern: &str| pattern_to_iso(text, pattern).filter(|iso| iso_to_pattern(iso, pattern).eq_ignore_ascii_case(text.trim())); + let first = date_pattern(Some(picture)); + if reads(&first).is_some() { + return None; + } + let iso = alternatives(picture).into_iter().skip(1).filter_map(date_letters).find_map(|p| reads(&p))?; + Some(iso_to_pattern(&iso, &first)) +} diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 0a192479a7..bdf6b6fbd4 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2141,3 +2141,18 @@ fn som_indices_count_earlier_same_named_siblings() { let sibs = SomIndices::of(&children); assert_eq!((0..=children.len()).map(|i| sibs.at(i)).collect::>(), [0, 0, 1, 0, 2, 1, 0]); } + +#[test] +fn a_date_typed_by_another_of_its_edit_pictures_patterns_is_written_in_the_first() { + let (tpl, _) = crate::parse(&crate::fixtures::date_alternatives_template()).unwrap(); + let typed = |som: &str, text: &str| crate::typed_date(&tpl, som, text); + let leave = "form[0].leave[0]"; + assert_eq!(typed(leave, "261018").as_deref(), Some("20261018"), "by yymmdd"); + assert_eq!(typed(leave, "2026118").as_deref(), Some("20261108"), "by yyyymd"); + assert_eq!(typed(leave, "26 10 18").as_deref(), Some("20261018"), "by yy mm dd"); + assert_eq!(typed(leave, "20261018"), None, "the first pattern reads it: it stays as typed"); + assert_eq!(typed(leave, "not a date"), None); + assert_eq!(typed(leave, "2618"), None, "no pattern has room for four digits"); + assert_eq!(typed("form[0].born[0]", "181026"), None, "one pattern: nothing else to try"); + assert_eq!(typed("form[0].name[0]", "261018"), None, "not a date field"); +} From e8eb840ec53d8fa1e1ac070b2e3b67c86bc21ecf Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:59:14 -0400 Subject: [PATCH 08/47] =?UTF-8?q?M6:=20form=20buttons=20show=20the=20side?= =?UTF-8?q?=20panels=20(execMenuItem=20ShowHide=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/automation/src/a11y.rs | 1 + crates/engine/src/js.rs | 11 ++++++++-- crates/engine/src/tests.rs | 27 ++++++++++++++++++++++++ crates/engine/src/xfa.rs | 2 ++ crates/js/src/lib.rs | 39 ++++++++++++++++++++++++++++++++++- crates/js/src/tests.rs | 36 ++++++++++++++++++++++++++++++++ crates/js/src/xfa.rs | 9 +++++--- crates/ui-egui/src/js_ui.rs | 13 +++++++++++- crates/ui-egui/tests/forms.rs | 15 ++++++++++++++ parity/acrobat-features.toml | 2 +- 10 files changed, 147 insertions(+), 8 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 2124549731..3a1422ec78 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -657,6 +657,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::Focus(f) => json!({ "focus": f }), R::Beep => json!({ "beep": true }), R::SaveAs => json!({ "save_as": true }), + R::ShowPanel(p) => json!({ "panel": format!("{p:?}").to_lowercase() }), } } diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index b797bc8d83..82ee90c5db 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -7,7 +7,7 @@ //! off, scripts don't run and AF calls keep working natively. use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts}; -pub use pdfcraft_js::{Limits, Outcome, Request}; +pub use pdfcraft_js::{Limits, Outcome, Panel, Request}; use crate::{DocId, Edit, EditError, Session}; @@ -29,7 +29,14 @@ impl JsOutput { fn absorb(&mut self, o: &Outcome) { self.alerts.extend(o.alerts.iter().cloned()); self.console.extend(o.console.iter().cloned()); - self.requests.extend(o.requests.iter().cloned()); + // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a + // button's may show or hide a side panel. + for r in &o.requests { + match r { + Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()), + r => self.requests.push(r.clone()), + } + } self.errors.extend(o.error.iter().cloned()); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41dac..72854e37ac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6106,3 +6106,30 @@ fn undo_never_makes_an_unsaved_document_clean() { s.undo(new).unwrap(); assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); } + +#[test] +fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("files.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + s.take_js_output(id); + s.apply(id, Edit::XfaEvent { som: "form[0].files[0]".into(), activity: "click".into() }).unwrap(); + let out = s.take_js_output(id); + assert_eq!(out.requests, [pdfcraft_js::Request::ShowPanel(pdfcraft_js::Panel::Attachments)], "{out:?}"); + s.apply(id, Edit::SetFieldValue { name: "note".into(), value: FieldValue::Text("x".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "only a click shows a panel: {out:?}"); + assert!(out.console.iter().any(|c| c.contains("show a side panel")), "{:?}", out.console); +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 0f37933afd..313bfd1caf 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -53,6 +53,7 @@ fn click_only(e: &XfaEffect) -> Option<&'static str> { XfaEffect::Print => Some("print"), XfaEffect::SaveAs => Some("save"), XfaEffect::LaunchUrl(_) => Some("open a link"), + XfaEffect::ShowPanel(_) => Some("show a side panel"), XfaEffect::SetFocus(_) => Some("move the focus"), _ => None, } @@ -626,6 +627,7 @@ impl<'a, 'b> Runner<'a, 'b> { XfaEffect::Print => self.run.out.requests.push(Request::Print), XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs), XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())), + XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)), XfaEffect::SetFocus(som) => { if let Some(name) = self.by_som().get(som).cloned() { self.run.out.requests.push(Request::Focus(name)); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index d8d9fa8bac..987d28da49 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -13,7 +13,7 @@ //! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`, //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, //! `setFocus`. -//! - `app` (`alert`, `beep`, `response`, `launchURL`, `viewerType`, `viewerVersion`, +//! - `app` (`alert`, `beep`, `response`, `launchURL`, `execMenuItem`, `viewerType`, `viewerVersion`, //! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`), `console` (`println`, //! `show`, `clear`), `display` and `color`. //! @@ -159,6 +159,19 @@ pub enum Request { Beep, /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). SaveAs, + /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. + ShowPanel(Panel), +} + +/// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Panel { + Bookmarks, + /// Page thumbnails. + Pages, + Attachments, + Signatures, + Layers, } /// What running a script did. @@ -565,6 +578,29 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Option { + Some(match name { + "ShowHideFileAttachment" => Request::ShowPanel(Panel::Attachments), + "ShowHideBookmarks" => Request::ShowPanel(Panel::Bookmarks), + "ShowHideThumbnails" => Request::ShowPanel(Panel::Pages), + "ShowHideSignatures" => Request::ShowPanel(Panel::Signatures), + "ShowHideOptCont" => Request::ShowPanel(Panel::Layers), + "Print" => Request::Print, + "SaveAs" | "Save" => Request::SaveAs, + _ => return None, + }) +} + +fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let item = text(&arg(args, 0), ctx)?; + if let Some(r) = menu_item(&item) { + host(ctx)?.borrow_mut().requests.push(r); + } + Ok(JsValue::undefined()) +} + fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { // No one to answer: as if the dialog was cancelled. Ok(JsValue::null()) @@ -931,6 +967,7 @@ fn install(ctx: &mut Context) -> JsResult<()> { .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0) .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) + .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 61d3a71e51..bfb53996fc 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1132,3 +1132,39 @@ mod session { assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}"); } } + +mod menu_items { + use super::*; + use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa}; + + #[test] + fn exec_menu_item_shows_side_panels_prints_and_saves() { + let o = go( + "app.execMenuItem('ShowHideFileAttachment'); app.execMenuItem('ShowHideBookmarks'); app.execMenuItem('ShowHideThumbnails'); \ + app.execMenuItem('ShowHideSignatures'); app.execMenuItem('ShowHideOptCont'); app.execMenuItem('Print'); \ + app.execMenuItem('SaveAs'); app.execMenuItem('NoSuchItem');", + &Event::field("Mouse Up", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!( + o.requests, + [ + Request::ShowPanel(Panel::Attachments), + Request::ShowPanel(Panel::Bookmarks), + Request::ShowPanel(Panel::Pages), + Request::ShowPanel(Panel::Signatures), + Request::ShowPanel(Panel::Layers), + Request::Print, + Request::SaveAs, + ] + ); + } + + #[test] + fn xfa_scripts_show_the_attachments_panel() { + let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() }; + let o = run_xfa("app.execMenuItem('ShowHideFileAttachment')", &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default()); + assert_eq!(o.error, None, "{o:?}"); + assert!(o.effects.contains(&XfaEffect::ShowPanel(Panel::Attachments)), "{:?}", o.effects); + } +} diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index a2361da7e4..8f64f9049c 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -112,6 +112,8 @@ pub enum XfaEffect { MessageBox(String), /// `xfa.host.resetData()`: the named fields, or every field when empty. ResetData(Vec), + /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. + ShowPanel(crate::Panel), Print, SaveAs, LaunchUrl(String), @@ -1049,9 +1051,10 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { let item = text(&arg(args, 0), ctx)?; - match item.as_str() { - "SaveAs" | "Save" => effect(ctx, XfaEffect::SaveAs)?, - "Print" => effect(ctx, XfaEffect::Print)?, + match crate::menu_item(&item) { + Some(crate::Request::SaveAs) => effect(ctx, XfaEffect::SaveAs)?, + Some(crate::Request::Print) => effect(ctx, XfaEffect::Print)?, + Some(crate::Request::ShowPanel(p)) => effect(ctx, XfaEffect::ShowPanel(p))?, _ => {} } Ok(JsValue::undefined()) diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index df461fc6e7..98b2afbd84 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -2,7 +2,7 @@ //! the JavaScript console (⌘J), Document JavaScripts, and Preferences ▸ JavaScript. use egui::{Align, Layout}; -use pdfcraft_engine::js::{JsOutput, Request}; +use pdfcraft_engine::js::{JsOutput, Panel, Request}; use pdfcraft_engine::{DocId, Edit}; use crate::theme::{self, Tokens}; @@ -67,6 +67,17 @@ impl PdfCraftApp { &[("u", &u)], ), Request::SaveAs => self.run_command("file.save_as"), + Request::ShowPanel(p) => { + let panel = match p { + Panel::Bookmarks => crate::RightPanel::Bookmarks, + Panel::Pages => crate::RightPanel::Pages, + Panel::Attachments => crate::RightPanel::Attachments, + Panel::Signatures => crate::RightPanel::Signatures, + Panel::Layers => crate::RightPanel::Layers, + }; + // Show/Hide: a panel already showing closes. + self.right = (self.right != Some(panel)).then_some(panel); + } Request::Focus(_) | Request::Beep | Request::Reset(_) => {} } } diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index e153dcca94..6b2add4866 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1072,3 +1072,18 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { // An explicit size is kept, as in Acrobat: the text may then run past the box. assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0); } + +#[test] +fn a_form_button_shows_and_hides_the_attachments_panel() { + let mut h = harness(); + h.state_mut().set_option("panel", "none").unwrap(); + h.run_steps(2); + let id = h.state().views[0].id; + h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');"); + h.run_steps(2); + assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Attachments)); + // Show/Hide, as its name says: asked again, it closes. + h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');"); + h.run_steps(2); + assert_eq!(h.state().right, None); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8b..f47140442c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5137,7 +5137,7 @@ status = "partial" commands = ["tools.js_console"] tools = ["js_run"] evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet." +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels, Print and Save As, from a button only), util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet." [[feature]] id = "form.js-console" From 74326b455c1073b3a3bd69d08f7900a0a4f329b7 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:02:37 -0400 Subject: [PATCH 09/47] M6: a field's own script may not open Save As --- crates/engine/src/js.rs | 5 ++++- crates/engine/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 82ee90c5db..73be056600 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -30,10 +30,13 @@ impl JsOutput { self.alerts.extend(o.alerts.iter().cloned()); self.console.extend(o.console.iter().cloned()); // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a - // button's may show or hide a side panel. + // button's may show or hide a side panel, or open Save As. for r in &o.requests { match r { Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()), + Request::SaveAs => { + self.console.push("A field script asked to save the document as a new file; only a button may open Save As".into()) + } r => self.requests.push(r.clone()), } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 72854e37ac..4c19b80119 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6133,3 +6133,26 @@ fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() { assert!(out.requests.is_empty(), "only a click shows a panel: {out:?}"); assert!(out.console.iter().any(|c| c.contains("show a side panel")), "{:?}", out.console); } + +#[test] +fn a_fields_own_script_may_not_save_as_or_show_a_panel() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + s.apply( + id, + Edit::SetFieldScript { + name: "qty".into(), + event: "validate".into(), + script: Some("app.execMenuItem('SaveAs'); app.execMenuItem('ShowHideFileAttachment');".into()), + }, + ) + .unwrap(); + s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "a validate script runs unasked: {out:?}"); + assert_eq!(out.console.iter().filter(|c| c.contains("only a button may")).count(), 2, "{:?}", out.console); + // A button's script may. + let o = s.run_javascript(id, "app.execMenuItem('SaveAs');", Some("go")).unwrap(); + assert_eq!(o.requests, [js::Request::SaveAs]); +} From 9366af3badb6ebfc096507b4add4e51779fbbd8a Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:01:10 -0400 Subject: [PATCH 10/47] M6: side panels have fixed automation ids, and the parity note says where Print works --- crates/automation/src/a11y.rs | 2 +- crates/js/src/lib.rs | 13 +++++++++++++ crates/js/src/tests.rs | 6 ++++++ parity/acrobat-features.toml | 2 +- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 3a1422ec78..a9e13cbc34 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -657,7 +657,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::Focus(f) => json!({ "focus": f }), R::Beep => json!({ "beep": true }), R::SaveAs => json!({ "save_as": true }), - R::ShowPanel(p) => json!({ "panel": format!("{p:?}").to_lowercase() }), + R::ShowPanel(p) => json!({ "panel": p.id() }), } } diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 987d28da49..281898c4ad 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -174,6 +174,19 @@ pub enum Panel { Layers, } +impl Panel { + /// The panel's name in automation output, fixed whatever the variant is called. + pub fn id(self) -> &'static str { + match self { + Panel::Bookmarks => "bookmarks", + Panel::Pages => "pages", + Panel::Attachments => "attachments", + Panel::Signatures => "signatures", + Panel::Layers => "layers", + } + } +} + /// What running a script did. #[derive(Clone, Debug, Default, PartialEq)] pub struct Outcome { diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index bfb53996fc..ec4cfcc5eb 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1137,6 +1137,12 @@ mod menu_items { use super::*; use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa}; + #[test] + fn each_panel_has_a_fixed_id_for_automation() { + let ids = [Panel::Bookmarks, Panel::Pages, Panel::Attachments, Panel::Signatures, Panel::Layers].map(Panel::id); + assert_eq!(ids, ["bookmarks", "pages", "attachments", "signatures", "layers"]); + } + #[test] fn exec_menu_item_shows_side_panels_prints_and_saves() { let o = go( diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f47140442c..12e11d82ca 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5137,7 +5137,7 @@ status = "partial" commands = ["tools.js_console"] tools = ["js_run"] evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels, Print and Save As, from a button only), util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet." +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet." [[feature]] id = "form.js-console" From c90afe54cfdf08cd27ce2ca43e091d36f889c647 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:22:03 -0400 Subject: [PATCH 11/47] M6: say why a form's Save opens Save As --- crates/js/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 281898c4ad..aff27f951f 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -601,6 +601,8 @@ pub(crate) fn menu_item(name: &str) -> Option { "ShowHideSignatures" => Request::ShowPanel(Panel::Signatures), "ShowHideOptCont" => Request::ShowPanel(Panel::Layers), "Print" => Request::Print, + // Save opens Save As too, as it always has for XFA forms' buttons: a click on a form + // never overwrites the file unasked. "SaveAs" | "Save" => Request::SaveAs, _ => return None, }) From 032febca2fbcbe2884d9d99347e7b0103d2023ef Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:42:56 -0400 Subject: [PATCH 12/47] M6: doc comments for the functions this change touches --- crates/js/src/lib.rs | 9 +++++++++ crates/js/src/tests.rs | 17 +++++++++++++++++ crates/js/src/xfa.rs | 3 +++ 3 files changed, 29 insertions(+) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 0e7de9891f..8f3bb412d8 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -825,6 +825,7 @@ enum DateUnit { } impl DateUnit { + /// A year, month or day: a format needs one of these for `util.scand` to read anything. fn is_date(self) -> bool { matches!(self, Self::Year | Self::ShortYear | Self::Month | Self::MonthName | Self::Day) } @@ -845,6 +846,9 @@ enum ScanPart { Word(String), } +/// The fields and literal words of a `util.scand` format, in order. A numeric field is marked +/// `joined` when another numeric field follows it directly; anything but letters and digits +/// outside a field is left out. fn scan_parts(fmt: &str) -> Vec { let chars: Vec = fmt.chars().collect(); let mut parts: Vec = Vec::new(); @@ -956,6 +960,8 @@ struct ScanValues { } impl ScanValues { + /// Records `v` as the value of `unit`: a `yy` year as 1950–2049, and an `h` or `hh` hour + /// marked as 12-hour. Month names, weekdays and am/pm are not read here. fn set(&mut self, unit: DateUnit, v: u32) { match unit { DateUnit::Year => self.y = i32::try_from(v).ok(), @@ -1202,6 +1208,9 @@ fn color_object(ctx: &mut Context) -> JsObject { o.build() } +/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, +/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, +/// `documentFileName`, `info`), and the `app`, `util`, `console`, `display` and `color` objects. fn install(ctx: &mut Context) -> JsResult<()> { let globals: [(&str, usize, NativeFunction); 9] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 791da93a24..c6b6d80d35 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1157,6 +1157,8 @@ mod scand { // The cases below are another viewer's answers, observed black-box with a synthetic file. + /// Any separator may come between fields, whether the format has that one, another or none, + /// but a text with a space or a letter before or after the date reads nothing. #[test] fn scand_allows_separators_between_fields_but_nothing_before_or_after_them() { assert_scand(&[ @@ -1171,6 +1173,8 @@ mod scand { ]); } + /// Digits typed together fill the joined fields with exactly as many digits as each field has + /// letters, so a run of any other length reads nothing. #[test] fn scand_splits_digits_typed_together_by_the_formats_letter_counts() { assert_scand(&[ @@ -1188,6 +1192,8 @@ mod scand { ]); } + /// A month past 12, a day past the month's end and February 29 outside a leap year are + /// refused, while a month or day of 0 is read as 1. #[test] fn scand_refuses_impossible_dates_and_reads_a_zero_month_or_day_as_1() { assert_scand(&[ @@ -1201,6 +1207,8 @@ mod scand { ]); } + /// A field typed by itself may have one or two digits, but a `yyyy` year needs four; `yy` reads + /// as 1950–2049; month names may be full or three letters in any case; weekday names are read. #[test] fn scand_reads_lone_fields_two_digit_years_and_month_and_weekday_names() { assert_scand(&[ @@ -1223,6 +1231,7 @@ mod scand { ]); } + /// A time is read along with a date, but a format with only time fields reads nothing. #[test] fn scand_reads_a_time_only_with_a_date() { assert_scand(&[ @@ -1236,6 +1245,8 @@ mod scand { ]); } + /// An empty text reads as today at noon, and a text that is not a string (a number, or none + /// at all) reads nothing. #[test] fn scand_reads_only_text_and_an_empty_text_is_today_at_noon() { let o = go( @@ -1250,6 +1261,8 @@ mod scand { // Not observed: the choices made where the other viewer wasn't asked. + /// A year left out is this year, `h:MM tt` reads a 12-hour time and refuses hours it cannot + /// have, and a letter the format writes must be typed there. #[test] fn scand_takes_this_year_when_none_is_given_and_reads_12_hour_times_and_written_letters() { assert_scand(&[ @@ -1268,6 +1281,8 @@ mod scand { assert_eq!(o.value, "true|9|18"); } + /// A format of 50,000 literal letters and one of 100,000 fields, with texts to match, are read + /// in under two seconds. #[test] fn scand_reads_long_formats_and_texts_in_linear_time() { // Scripts can pass anything; native code isn't bounded by the loop limit. @@ -1282,6 +1297,8 @@ mod scand { assert!(started.elapsed() < std::time::Duration::from_secs(2), "{:?}", started.elapsed()); } + /// The check an XFA form runs on leaving a date field tries six formats with `util.scand` and + /// writes the first that reads the text back as `yyyymmdd`, or reports an invalid date. #[test] fn xfa_scripts_check_typed_dates_with_util_scand() { // The check a form runs when a date field is left: the first of six formats that reads diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 9a48ff16ab..ef06e8418c 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1098,6 +1098,9 @@ fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { resolver(ctx, current, many) } +/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, +/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, +/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { let rw = Attribute::CONFIGURABLE; let form = node_object(ctx, 0)?; From a0ae8a39961de99120d7e027660c97f0f049d052 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:43:02 -0400 Subject: [PATCH 13/47] M6: doc comments for the functions this change touches --- crates/automation/src/a11y.rs | 2 ++ crates/engine/src/js.rs | 3 +++ crates/engine/src/tests.rs | 4 ++++ crates/js/src/lib.rs | 5 +++++ crates/js/src/tests.rs | 4 ++++ crates/js/src/xfa.rs | 2 ++ crates/ui-egui/tests/forms.rs | 2 ++ 7 files changed, 22 insertions(+) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index a9e13cbc34..19afb16a45 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -646,6 +646,8 @@ impl Automation { } } +/// A script's request to the viewer as the JSON that running a script reports; a side panel +/// goes by its fixed id. fn request_json(r: &pdfcraft_engine::js::Request) -> Value { use pdfcraft_engine::js::Request as R; match r { diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 73be056600..9b057900fc 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -26,6 +26,9 @@ impl JsOutput { self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty() } + /// Adds what a field's own script produced: its alerts, console lines, errors and requests, + /// except a request to show a side panel or open Save As, which is noted on the console + /// and dropped. fn absorb(&mut self, o: &Outcome) { self.alerts.extend(o.alerts.iter().cloned()); self.console.extend(o.console.iter().cloned()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4c19b80119..6b0a97e98e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6107,6 +6107,8 @@ fn undo_never_makes_an_unsaved_document_clean() { assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); } +/// An XFA button's click script may show the attachments panel; a text field's exit script +/// asking the same is noted on the console and ignored. #[test] fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() { let xdp = r##" @@ -6134,6 +6136,8 @@ fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() { assert!(out.console.iter().any(|c| c.contains("show a side panel")), "{:?}", out.console); } +/// A validate script that asks for Save As and a side panel gets neither, with a console note +/// for each; a button's script may still open Save As. #[test] fn a_fields_own_script_may_not_save_as_or_show_a_panel() { let mut s = Session::new().with_clock(|| 1_700_000_000); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index aff27f951f..1eb3d60e89 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -608,6 +608,8 @@ pub(crate) fn menu_item(name: &str) -> Option { }) } +/// `app.execMenuItem(name)`: queues the request [`menu_item`] gives for the name (Save opens +/// Save As too); an unknown name does nothing. fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let item = text(&arg(args, 0), ctx)?; if let Some(r) = menu_item(&item) { @@ -941,6 +943,9 @@ fn color_object(ctx: &mut Context) -> JsObject { o.build() } +/// Installs the form object model's globals: the document's functions and properties +/// (`getField`, `numFields`, `pageNum`, `info`, …), `app`, `util`, `console`, `display` and +/// `color`. fn install(ctx: &mut Context) -> JsResult<()> { let globals: [(&str, usize, NativeFunction); 9] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index ec4cfcc5eb..a78ce67de7 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1137,12 +1137,15 @@ mod menu_items { use super::*; use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa}; + /// Every side panel has a fixed id in automation output. #[test] fn each_panel_has_a_fixed_id_for_automation() { let ids = [Panel::Bookmarks, Panel::Pages, Panel::Attachments, Panel::Signatures, Panel::Layers].map(Panel::id); assert_eq!(ids, ["bookmarks", "pages", "attachments", "signatures", "layers"]); } + /// Each side panel item, Print and SaveAs make their request, in order; an unknown item + /// makes none and is not an error. #[test] fn exec_menu_item_shows_side_panels_prints_and_saves() { let o = go( @@ -1166,6 +1169,7 @@ mod menu_items { ); } + /// `app.execMenuItem` works in XFA scripts too: a click shows the attachments panel. #[test] fn xfa_scripts_show_the_attachments_panel() { let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() }; diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 8f64f9049c..093ba633b8 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1049,6 +1049,8 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { let item = text(&arg(args, 0), ctx)?; match crate::menu_item(&item) { diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 6b2add4866..47a5c8b521 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1073,6 +1073,8 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0); } +/// A button script's `ShowHideFileAttachment` opens the attachments panel, and running it again +/// closes it. #[test] fn a_form_button_shows_and_hides_the_attachments_panel() { let mut h = harness(); From b6cd6b44ca57e82ffc90580a17737a95899ab6b6 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:24:10 -0400 Subject: [PATCH 14/47] M6: doc comments for the functions around this change --- crates/automation/src/a11y.rs | 9 +++++++++ crates/engine/src/js.rs | 1 + crates/js/src/lib.rs | 9 +++++++++ crates/js/src/tests.rs | 10 ++++++++++ crates/js/src/xfa.rs | 11 +++++++++++ crates/ui-egui/tests/forms.rs | 3 +++ 6 files changed, 43 insertions(+) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 19afb16a45..a11f1132f9 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -575,6 +575,8 @@ impl Automation { } impl Automation { + /// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused + /// unless the models read it) and `skip_text_pages`, each left at its default when absent. pub(crate) fn ocr_settings(&self, a: &Args) -> Result { let mut settings = pdfcraft_engine::ocr::OcrSettings::default(); if let Some(d) = a.opt_num("dpi")? { @@ -592,6 +594,9 @@ impl Automation { Ok(settings) } + /// Recognizes text in each of `paths` and writes the result under the same file name in + /// `folder`, reporting per file its output, word count and skipped pages, or its error. A + /// path outside the root refuses the whole call before any work starts. pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result { let settings = self.ocr_settings(a)?; let folder = self.resolve(a.str("folder")?, true)?; @@ -623,6 +628,8 @@ impl Automation { Ok(json!({ "files": out })) } + /// Recognizes text on the open document's `pages` (every page when absent) and adds it, + /// reporting the total word count and, per page, why it was skipped or what it found. pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { let settings = self.ocr_settings(a)?; let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; @@ -638,6 +645,8 @@ impl Automation { Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) } + /// Whether text recognition's models are installed, the directories searched for them (in + /// order), and the languages it reads, each as a code and a name. pub(crate) fn ocr_status(&self) -> Result { use pdfcraft_engine::ocr; let dirs: Vec = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect(); diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 9b057900fc..025037b8f1 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -22,6 +22,7 @@ pub struct JsOutput { } impl JsOutput { + /// Whether scripts produced nothing: no alerts, console lines, requests or errors. pub fn is_empty(&self) -> bool { self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty() } diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 1eb3d60e89..6b4e7f8750 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -573,6 +573,8 @@ fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult { } } +/// `app.alert(cMsg)`: records the message, given as a string or as an object with `cMsg`, and +/// returns 1 (the OK button). fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = message(&arg(args, 0), "cMsg", ctx)?; host(ctx)?.borrow_mut().alerts.push(m); @@ -580,11 +582,14 @@ fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Ok(JsValue::from(1)) } +/// `app.beep()`: queues a request to beep. fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { host(ctx)?.borrow_mut().requests.push(Request::Beep); Ok(JsValue::undefined()) } +/// `app.launchURL(cURL)`: queues a request to open the URL, given as a string or as an object +/// with `cURL`. fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = message(&arg(args, 0), "cURL", ctx)?; host(ctx)?.borrow_mut().requests.push(Request::LaunchUrl(u)); @@ -618,6 +623,8 @@ fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult< Ok(JsValue::undefined()) } +/// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to +/// type one. fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { // No one to answer: as if the dialog was cancelled. Ok(JsValue::null()) @@ -920,6 +927,8 @@ fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Ok(s(&out)) } +/// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as +/// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components. fn color_object(ctx: &mut Context) -> JsObject { let mut entries: Vec<(&str, Vec)> = vec![ ("transparent", vec![s("T")]), diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index a78ce67de7..e3ce86efe8 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1040,6 +1040,8 @@ mod session { use crate::xfa::*; use crate::xfa_session::{Script, XfaSession}; + /// A JavaScript or FormCalc script for `activity` on `target`, against the test form with + /// default limits and a 3 s timeout. fn script(source: &str, formcalc: bool, target: &str, activity: &str) -> Script { Script { source: source.into(), @@ -1051,6 +1053,9 @@ mod session { } } + /// JavaScript and FormCalc scripts run in turn in one session, each on the form the caller + /// sends: nothing an earlier script changed or defined leaks into the next, a failing script + /// doesn't end the session, and hundreds of scripts run on its one thread. #[test] fn scripts_of_one_event_run_on_one_thread_each_on_the_form_the_caller_sends() { let mut s = XfaSession::new(XfaDoc { page_count: 3, ..Default::default() }); @@ -1090,6 +1095,8 @@ mod session { assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); } + /// A JavaScript or FormCalc script whose engine takes longer to be ready than the script's + /// time limit still runs and returns its result, and the session stays alive. #[test] fn a_scripts_time_starts_when_its_context_is_ready() { // The engine made slow to be ready (300 ms) under a 50 ms script limit: the script is @@ -1110,6 +1117,9 @@ mod session { assert_eq!(s.dead(), None); } + /// An endless JavaScript loop past its 200 ms limit is abandoned within 2 s and leaves the + /// session dead, so nothing more runs in it; an endless FormCalc loop is abandoned or + /// reported as running too long. #[test] fn a_script_past_its_time_is_abandoned_and_the_session_is_dead_after_it() { let mut s = XfaSession::new(XfaDoc::default()); diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 093ba633b8..6b87b2e571 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -997,17 +997,22 @@ fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult { // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── +/// Records effect `e` on the host, as [`XHost::push`] does. fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { host(ctx)?.borrow_mut().push(e); Ok(()) } +/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and +/// returns 1. fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = clip(text(&arg(args, 0), ctx)?); effect(ctx, XfaEffect::MessageBox(m))?; Ok(JsValue::from(1.0)) } +/// `xfa.host.resetData(names)`: asks to reset the comma-separated fields, or every field when +/// the list is empty. fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let list = text(&arg(args, 0), ctx)?; let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); @@ -1015,22 +1020,28 @@ fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { effect(ctx, XfaEffect::Print)?; Ok(JsValue::undefined()) } +/// `xfa.host.beep()`: asks to beep. fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Beep)?; Ok(JsValue::undefined()) } +/// `xfa.host.gotoURL(url)`: asks to open the URL. fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = text(&arg(args, 0), ctx)?; effect(ctx, XfaEffect::LaunchUrl(u))?; Ok(JsValue::undefined()) } +/// `xfa.host.setFocus(target)`: asks to focus the node, given as a node object or as a SOM +/// expression resolved from the current node; an expression that names no node is passed on +/// as written. fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let target = arg(args, 0); let som = match target.as_object() { diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 47a5c8b521..85b0ac03d0 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1050,6 +1050,9 @@ fn on_screen_fit(h: &Harness<'static, PdfCraftApp>, name: &str, text: &str) -> ( } } +/// Text typed into an auto-size field shrinks to fit the box's width, while short text keeps a +/// larger size; a multiline auto-size field wraps and shrinks only as far as the box's height +/// needs, and a fixed 10 pt field keeps its size. #[test] fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { let mut h = auto_size_harness(); From 4ee87801b255b0d96bc0ba64c9ed187d432d5f5c Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:48:31 -0400 Subject: [PATCH 15/47] M6: util.scand's linear-time test allows a loaded runner (10 s) --- crates/js/src/tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index b871ed6c3f..d155251638 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1293,7 +1293,8 @@ mod scand { } /// A format of 50,000 literal letters and one of 100,000 fields, with texts to match, are read - /// in under two seconds. + /// in linear time. The limit is generous so a loaded runner passes (it also covers starting + /// the engine and building the strings); a quadratic scan of inputs this size takes minutes. #[test] fn scand_reads_long_formats_and_texts_in_linear_time() { // Scripts can pass anything; native code isn't bounded by the loop limit. @@ -1305,7 +1306,7 @@ mod scand { ); assert_eq!(o.error, None); assert_eq!(o.value, "true|true"); - assert!(started.elapsed() < std::time::Duration::from_secs(2), "{:?}", started.elapsed()); + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); } /// The check an XFA form runs on leaving a date field tries six formats with `util.scand` and From 4d22d2b74ae2b5ea72f565808c1b29608db30421 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:17:40 -0400 Subject: [PATCH 16/47] M12: doc comments on the picture-alternatives tests --- crates/engine/src/tests.rs | 2 ++ crates/xfa/src/tests.rs | 3 +++ 2 files changed, 5 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 95ace8c2d2..d8a680880d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7101,6 +7101,8 @@ fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}"); } +/// A date typed in another of an XFA date field's edit patterns is kept in its first: yymmdd and +/// yyyymd are rewritten as yyyymmdd, a date none of them reads is refused, and the data holds the date. #[test] fn a_date_typed_by_another_of_an_xfa_fields_edit_patterns_is_kept_in_its_first() { let mut s = Session::new(); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index dbae4ef911..87875211fe 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2658,6 +2658,9 @@ fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() { assert!((left_edge("right", "TITLE ") - (36.0 + w - ink)).abs() < 1e-6); } +/// `typed_date` rewrites a date typed by another of a field's edit-picture patterns in the first +/// one, and leaves alone a date the first pattern reads, text no pattern reads, a field with one +/// pattern and a field that isn't a date. #[test] fn a_date_typed_by_another_of_its_edit_pictures_patterns_is_written_in_the_first() { let (tpl, _) = crate::parse(&crate::fixtures::date_alternatives_template()).unwrap(); From 9dab857ca3babe685e7187f63ceac0f01da2feb8 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:48:21 -0400 Subject: [PATCH 17/47] M12: only date clauses are other ways to type a date, and a date is rewritten only into a first pattern that keeps it --- crates/xfa/src/fixtures.rs | 5 ++++- crates/xfa/src/picture.rs | 23 +++++++++++++++++++---- crates/xfa/src/tests.rs | 13 +++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 9e76fbbaea..9b4988aef3 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -506,7 +506,8 @@ pub fn fields_template(data: &str) -> String { } /// Date fields whose edit pictures list alternatives, as forms let a date be typed several ways -/// (`date{YYYYMMDD}|date{YYMMDD}|…`); one with a single pattern; a text field. +/// (`date{YYYYMMDD}|date{YYMMDD}|…`); one with a single pattern; a text field; `code`, whose +/// only alternative is a text clause; and `short`, whose first pattern has a two-digit year. pub fn date_alternatives_template() -> String { r##" @@ -516,6 +517,8 @@ pub fn date_alternatives_template() -> String { date{YYYYMMDD}|date{DDMMMYY}|date{YYMMDD}|date{YYYYMMD}|date{YY MM DD} date{YYYY-MM-DD} + date{YYYYMMDD}|text{YYMMDD} + date{YYMMDD}|date{YYYYMMDD} diff --git a/crates/xfa/src/picture.rs b/crates/xfa/src/picture.rs index 58bd48cd69..2da57a2b0d 100644 --- a/crates/xfa/src/picture.rs +++ b/crates/xfa/src/picture.rs @@ -29,8 +29,9 @@ pub(crate) fn alternatives(picture: &str) -> Vec<&str> { /// `text` typed into the date field at `som` by one of its edit picture's alternatives after the /// first, written in the first, which the field shows and checks typed dates against (`261018` /// becomes `20261018` for `date{YYYYMMDD}|date{YYMMDD}`). A pattern reads the text only when the -/// date it gives, written in that pattern, is the text again. `None` when the field isn't a date -/// field with alternatives, the first pattern reads the text, or no other does. +/// date it gives, written in that pattern, is the text again, and only date clauses count. `None` +/// when the field isn't a date field with alternatives, the first pattern reads the text, no +/// other does, or the first pattern can't hold the date (a two-digit year for 1901). pub fn typed_date(tpl: &Template, som: &str, text: &str) -> Option { let f = template_field(tpl, som)?; if f.ui != Ui::DateTimeEdit { @@ -42,6 +43,20 @@ pub fn typed_date(tpl: &Template, som: &str, text: &str) -> Option { if reads(&first).is_some() { return None; } - let iso = alternatives(picture).into_iter().skip(1).filter_map(date_letters).find_map(|p| reads(&p))?; - Some(iso_to_pattern(&iso, &first)) + let iso = alternatives(picture).into_iter().skip(1).filter(|a| is_date_clause(a)).filter_map(date_letters).find_map(|p| reads(&p))?; + // Written in the first pattern only when it reads back as the same date: a year a two-digit + // year can't hold would otherwise become another century's. + let shown = iso_to_pattern(&iso, &first); + (pattern_to_iso(&shown, &first).as_deref() == Some(iso.as_str())).then_some(shown) +} + +/// Whether picture clause `clause` is a date pattern: a `date{…}` clause (with or without a +/// locale, `date(en_US){…}`), or a bare pattern, which a date field reads as a date. A clause of +/// another category (`text{…}`, `num{…}`, `time{…}`) is not, whatever its letters. +fn is_date_clause(clause: &str) -> bool { + let clause = clause.trim(); + match clause.find(['{', '(']) { + Some(i) => clause.get(..i).is_some_and(|category| category.trim().eq_ignore_ascii_case("date")), + None => true, + } } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 87875211fe..da9c8832ec 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2675,3 +2675,16 @@ fn a_date_typed_by_another_of_its_edit_pictures_patterns_is_written_in_the_first assert_eq!(typed("form[0].born[0]", "181026"), None, "one pattern: nothing else to try"); assert_eq!(typed("form[0].name[0]", "261018"), None, "not a date field"); } + +/// Only date clauses are other ways to type a date, and a date is rewritten only when the first +/// pattern keeps it: a later `text{…}` clause is no date pattern, and a year a two-digit first +/// pattern can't hold leaves the text as typed instead of becoming another date. +#[test] +fn a_typed_date_is_rewritten_only_by_date_clauses_into_a_first_pattern_that_keeps_it() { + let (tpl, _) = crate::parse(&crate::fixtures::date_alternatives_template()).unwrap(); + let typed = |som: &str, text: &str| crate::typed_date(&tpl, som, text); + assert_eq!(typed("form[0].code[0]", "261018"), None, "text{{YYMMDD}} is not a date alternative"); + let short = "form[0].short[0]"; + assert_eq!(typed(short, "20261018").as_deref(), Some("261018"), "a year the first pattern keeps"); + assert_eq!(typed(short, "19011018"), None, "1901 as 01 would read back as 2001"); +} From c9fa6a76278c78ea2dc6dd7711528881d6bcd580 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:01:48 -0400 Subject: [PATCH 18/47] M12: a date pattern whose date the first can't keep gives way to a later one --- crates/xfa/src/fixtures.rs | 4 +++- crates/xfa/src/picture.rs | 13 ++++++++----- crates/xfa/src/tests.rs | 7 +++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 9b4988aef3..cf9bf7ff56 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -507,7 +507,8 @@ pub fn fields_template(data: &str) -> String { /// Date fields whose edit pictures list alternatives, as forms let a date be typed several ways /// (`date{YYYYMMDD}|date{YYMMDD}|…`); one with a single pattern; a text field; `code`, whose -/// only alternative is a text clause; and `short`, whose first pattern has a two-digit year. +/// only alternative is a text clause; `short`, whose first pattern has a two-digit year; and +/// `pick`, where two later patterns read some dates and only one gives a year the first keeps. pub fn date_alternatives_template() -> String { r##" @@ -519,6 +520,7 @@ pub fn date_alternatives_template() -> String { date{YYYYMMDD}|text{YYMMDD} date{YYMMDD}|date{YYYYMMDD} + date{YYMMDD}|date{DDMMYYYY}|date{YYYYMMDD} diff --git a/crates/xfa/src/picture.rs b/crates/xfa/src/picture.rs index 2da57a2b0d..3ddb392114 100644 --- a/crates/xfa/src/picture.rs +++ b/crates/xfa/src/picture.rs @@ -43,11 +43,14 @@ pub fn typed_date(tpl: &Template, som: &str, text: &str) -> Option { if reads(&first).is_some() { return None; } - let iso = alternatives(picture).into_iter().skip(1).filter(|a| is_date_clause(a)).filter_map(date_letters).find_map(|p| reads(&p))?; - // Written in the first pattern only when it reads back as the same date: a year a two-digit - // year can't hold would otherwise become another century's. - let shown = iso_to_pattern(&iso, &first); - (pattern_to_iso(&shown, &first).as_deref() == Some(iso.as_str())).then_some(shown) + // The first later date pattern that reads the text and gives a date the first pattern keeps: + // written in the first, it must read back as the same date (a two-digit year can't hold 1901 + // or 1203), else the next pattern is tried. + alternatives(picture).into_iter().skip(1).filter(|a| is_date_clause(a)).filter_map(date_letters).find_map(|p| { + let iso = reads(&p)?; + let shown = iso_to_pattern(&iso, &first); + (pattern_to_iso(&shown, &first).as_deref() == Some(iso.as_str())).then_some(shown) + }) } /// Whether picture clause `clause` is a date pattern: a `date{…}` clause (with or without a diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index da9c8832ec..24872b35d9 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2677,8 +2677,9 @@ fn a_date_typed_by_another_of_its_edit_pictures_patterns_is_written_in_the_first } /// Only date clauses are other ways to type a date, and a date is rewritten only when the first -/// pattern keeps it: a later `text{…}` clause is no date pattern, and a year a two-digit first -/// pattern can't hold leaves the text as typed instead of becoming another date. +/// pattern keeps it: a later `text{…}` clause is no date pattern, a year a two-digit first +/// pattern can't hold leaves the text as typed instead of becoming another date, and a pattern +/// whose date the first can't keep gives way to a later one whose date it can. #[test] fn a_typed_date_is_rewritten_only_by_date_clauses_into_a_first_pattern_that_keeps_it() { let (tpl, _) = crate::parse(&crate::fixtures::date_alternatives_template()).unwrap(); @@ -2687,4 +2688,6 @@ fn a_typed_date_is_rewritten_only_by_date_clauses_into_a_first_pattern_that_keep let short = "form[0].short[0]"; assert_eq!(typed(short, "20261018").as_deref(), Some("261018"), "a year the first pattern keeps"); assert_eq!(typed(short, "19011018"), None, "1901 as 01 would read back as 2001"); + // DDMMYYYY reads 20011203 as 1203-01-20, which YYMMDD can't keep; YYYYMMDD reads 2001-12-03. + assert_eq!(typed("form[0].pick[0]", "20011203").as_deref(), Some("011203"), "the next pattern that the first keeps"); } From ae69aea6273499f1eee5ec869518a42a707d0117 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:22:27 -0400 Subject: [PATCH 19/47] M6: doc comments on every Panel variant --- crates/js/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 3299f9d6ec..4a4b5e64be 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -167,11 +167,15 @@ pub enum Request { /// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Panel { + /// The document's bookmarks (outline). Bookmarks, /// Page thumbnails. Pages, + /// The files attached to the document. Attachments, + /// The document's signatures and their validity. Signatures, + /// The document's layers (optional content). Layers, } From dd6d7feadb1024b519035bd9c099e1849e541f3d Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:18:11 -0400 Subject: [PATCH 20/47] M12: a date is tried in the field's first pattern again only when its format refused it --- crates/engine/src/lib.rs | 13 ++++++++----- crates/engine/src/tests.rs | 18 ++++++++++++++++++ crates/forms/src/lib.rs | 6 +++++- crates/forms/src/tests.rs | 2 +- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bde8f09608..8ff666b861 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2534,13 +2534,16 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), None => pdfcraft_forms::set_value(doc, name, value), }; - if let Err(refused) = set(doc, value) { - // A date the field's pattern doesn't read may be typed by another of an XFA date - // field's patterns: it is kept in the first. - match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { + match set(doc, value) { + Ok(()) => {} + // A date the field's format doesn't read may be typed by another of an XFA date + // field's patterns: it is kept in the first. Only a format refusal is tried + // again: it comes before any of the field's scripts run, so they run once. + Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { Some(typed) => set(doc, &typed)?, None => return Err(refused.into()), - } + }, + Err(refused) => return Err(refused.into()), } } Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed9e22c6b1..11963bdd8a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7305,3 +7305,21 @@ fn a_date_typed_by_another_of_an_xfa_fields_edit_patterns_is_kept_in_its_first() let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).expect("a datasets packet"); assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].leave[0]")), Some("2026-11-08")); } + +/// A date the field's own scripts refuse is not tried again in another of an XFA date field's +/// patterns: only a date its format refuses is, which happens before any script runs, so the +/// scripts run once. This validate script counts its runs in `name` and accepts on its second. +#[test] +fn a_date_its_validate_script_refuses_is_not_tried_again() { + let mut s = Session::new(); + let id = s + .open("dates.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::date_alternatives_template())), None) + .expect("opens"); + let script = Some("var n = Number(getField('name').value || 0) + 1; getField('name').value = String(n); event.rc = n >= 2;".to_string()); + s.apply(id, Edit::SetFieldScript { name: "leave".into(), event: "validate".into(), script }).unwrap(); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + // Its format reads 18OCT26, and so does its DDMMMYY pattern. + let refused = s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("18OCT26".into()) }); + assert!(refused.is_err(), "the validate script refused it, once"); + assert!(value(&s, "name").is_empty(), "nothing the refused run changed is kept: {:?}", value(&s, "name")); +} diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 7bd87a2efc..d4dc567480 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -48,6 +48,10 @@ pub enum FormError { ReadOnly(String), #[error("{0}")] Invalid(String), + /// The value doesn't fit the field's format (its keystroke check refused it), which is + /// checked before any of the field's scripts run. + #[error("{0}")] + Format(String), #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } @@ -1044,7 +1048,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut return invalid(format!("{:?} takes at most {max} characters", f.name)); } // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. - let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Invalid)?; + let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; let t = &scripting::accept(doc, f, t, scripts)?; doc.update_dict(f.obj, |d| { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b2b8573ebb..df8d8c35ff 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1270,7 +1270,7 @@ fn formats_validation_and_calculations_run_like_acrobat() { // Keystroke and validation errors use Acrobat's messages and change nothing. assert_eq!( set_value(&mut doc, "Qty", &FieldValue::Text("lots".into())), - Err(FormError::Invalid("The value entered does not match the format of the field [ Qty ]".into())) + Err(FormError::Format("The value entered does not match the format of the field [ Qty ]".into())) ); assert_eq!( set_value(&mut doc, "Qty", &FieldValue::Text("120".into())), From e76ebb9ea3ffc1642cbcf5cf5347b2952e19618e Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:20:36 +0800 Subject: [PATCH 21/47] M6: a document script that throws no longer stops the form's other scripts (#3940) Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/a11y.rs | 4 +++- crates/automation/src/tools.rs | 2 +- crates/engine/src/js.rs | 7 +++++++ crates/engine/src/tests.rs | 28 +++++++++++++++++++++++++++- crates/js/src/lib.rs | 12 ++++++++++-- crates/js/src/tests.rs | 28 ++++++++++++++++++++++++++++ 6 files changed, 76 insertions(+), 5 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 21bde353e9..6392454dbb 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -669,7 +669,9 @@ impl Automation { let id = self.doc(a)?.id; let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?; let reqs: Vec = o.requests.iter().map(request_json).collect(); - Ok(json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "result": o.result })) + Ok( + json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }), + ) } pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4fff59c07d..e5c901717f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1094,7 +1094,7 @@ pub fn tools() -> Vec { t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), - t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.") + t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).") .cmd("tools.js_console") .with(schema( json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index a59743dbbb..28e593531b 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -31,6 +31,13 @@ impl JsOutput { self.console.extend(o.console.iter().cloned()); self.requests.extend(o.requests.iter().cloned()); self.errors.extend(o.error.iter().cloned()); + // The document scripts run before every field script, so a failing one would repeat + // once per calculation; it is reported once. + for e in &o.doc_errors { + if !self.errors.contains(e) { + self.errors.push(e.clone()); + } + } } pub(crate) fn append(&mut self, other: JsOutput) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c8f0eb8861..288ebec0da 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4015,6 +4015,11 @@ fn recognize_text_reads_a_picture_on_a_page_that_has_text() { /// A form whose scripts are custom JavaScript: total = price × qty (calculate, through a /// document-level function), shown with a custom format; qty is validated; a button script. fn scripted_form() -> Vec { + scripted_form_with("function times\\(a, b\\) { console.println('times'); return a * b; }") +} + +/// [`scripted_form`] with `helpers` (PDF string syntax) as its document-level script. +fn scripted_form_with(helpers: &str) -> Vec { let text = |name: &str, y: u32, aa: &str| { format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [20 {y} 180 {}] /P 3 0 R /F 4 {aa} >>", y + 20) }; @@ -4028,7 +4033,7 @@ fn scripted_form() -> Vec { "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 150 80 170] /P 3 0 R /F 4 >>".to_string(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), "<< >>".to_string(), - "<< /S /JavaScript /JS (function times\\(a, b\\) { console.println('times'); return a * b; }) >>".to_string(), + format!("<< /S /JavaScript /JS ({helpers}) >>"), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -4090,6 +4095,27 @@ fn scripts_in_an_xfa_form_see_the_xfa_plugin_installed() { assert_eq!((o.error, o.result.as_deref()), (None, Some("undefined number 3.5"))); } +/// #3940: a document-level script that defines the helper and then calls a method PdfCraft lacks +/// (an Acrobat form copying a button icon at open) left every calculated field blank. The +/// helper stays defined, the form calculates, and the error is reported once. +#[test] +fn a_document_script_that_throws_still_lets_the_form_calculate() { + let helpers = + "function times\\(a, b\\) { return a * b; } this.getField\\('go'\\).buttonSetIcon\\(this.getField\\('go'\\).buttonGetIcon\\(\\)\\);"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form_with(helpers)), None).unwrap(); + let _ = s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); + let total = s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.clone(); + assert_eq!(total, ["10"], "calculated through the helper the failing script defined"); + let out = s.take_js_output(id); + assert_eq!(out.errors.len(), 1, "reported once, not once per script: {out:?}"); + assert!(out.errors[0].starts_with("document script 1: TypeError"), "{out:?}"); + // The console runs too, and says which document script failed. + let o = s.run_javascript(id, "times(2, 3)", None).unwrap(); + assert_eq!((o.error, o.result.as_deref(), o.doc_errors.len()), (None, Some("6"), 1)); +} + #[test] fn edit_history_does_not_replay_field_script_side_effects() { let mut s = Session::new().with_clock(|| 1_700_000_000); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 13bdc4ba36..4a2c2f666c 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -181,6 +181,9 @@ pub struct Outcome { pub requests: Vec, /// The script's error (syntax error, exception, or a limit reached), if it failed. pub error: Option, + /// Errors in the document-level scripts that ran first. Each failing one stops where it + /// failed; the others and the script itself still run, as in Acrobat (#3940). + pub doc_errors: Vec, /// The script's completion value (what the console prints), unless undefined. pub result: Option, } @@ -1281,8 +1284,13 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc Date: Thu, 22 Oct 2026 14:27:13 +0800 Subject: [PATCH 22/47] M6: js_run reports the document scripts that failed (#3940) Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/it/automation.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 778deaaec4..1a5709c18c 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5845,6 +5845,12 @@ fn javascript_through_tools() { assert_eq!(total["value"], "3750"); let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "nope()" })); assert!(r["error"].as_str().unwrap().contains("nope")); + assert_eq!(r["document_errors"], json!([])); + // A document script that throws (#3940) is reported, and the others and the script still run. + ok(&mut a, "js_set_document_script", json!({ "doc": doc, "name": "zz", "script": "getField('Total').buttonSetIcon(null);" })); + let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "money(5)" })); + assert_eq!((r["error"].clone(), r["result"].clone()), (json!(null), json!("EUR 5,00"))); + assert!(r["document_errors"][0].as_str().unwrap().starts_with("document script 2: TypeError"), "{r}"); assert_eq!(ok(&mut a, "js_enabled", json!({ "enabled": false }))["enabled"], false); assert!(a.call("js_run", &json!({ "doc": doc, "script": "1" })).is_err()); } From 77db1bd27faf87792358e089b7ff437b09a0e2c3 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:33:26 +0800 Subject: [PATCH 23/47] M6: parity note for document scripts that throw (#3940) Co-Authored-By: Claude Opus 5.5 --- crates/js/src/lib.rs | 4 ++-- parity/acrobat-features.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 4a2c2f666c..81633d597d 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -181,8 +181,8 @@ pub struct Outcome { pub requests: Vec, /// The script's error (syntax error, exception, or a limit reached), if it failed. pub error: Option, - /// Errors in the document-level scripts that ran first. Each failing one stops where it - /// failed; the others and the script itself still run, as in Acrobat (#3940). + /// Errors in the document-level scripts that ran first. Each is a script of its own: one + /// that throws stops where it failed, and the others and the script itself still run (#3940). pub doc_errors: Vec, /// The script's completion value (what the console prints), unless undefined. pub result: Option, diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 973e0cc697..f416435ae1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5141,8 +5141,8 @@ milestone = "M6" status = "shipped" commands = ["tools.document_js"] tools = ["js_document_scripts", "js_set_document_script"] -evidence = ["crates/ui-egui/tests/it/javascript.rs::document_scripts_and_preferences", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats"] -notes = "Names /JavaScript tree: list, add, edit, delete; functions are available to field scripts." +evidence = ["crates/ui-egui/tests/it/javascript.rs::document_scripts_and_preferences", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/engine/src/tests.rs::a_document_script_that_throws_still_lets_the_form_calculate"] +notes = "Names /JavaScript tree: list, add, edit, delete; functions are available to field scripts. A script that throws (calling an Acrobat method PdfCraft lacks, say) stops where it failed, but the others and the field scripts still run and the error is reported once (js_run's document_errors, the console) (#3940). Not like Acrobat: the scripts run again before every field script instead of once when the document opens, so what they do at top level repeats." [[feature]] id = "form.document-actions" From a8beb90ccfb4bb51e652dfa2515b663a683660c9 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:58:37 +0800 Subject: [PATCH 24/47] M6: a document script refused before running is skipped and reported too (#3940) CodeRabbit on #3985: a document script over the size or nesting limit made `run` return before any script ran, so the field script failed as before. It is now skipped (never evaluated, as the limits require), reported in its place in doc_errors, and the other scripts and the field script still run. The field script's own limits still refuse it outright. Co-Authored-By: Claude Opus 5.5 --- crates/js/src/lib.rs | 45 ++++++++++++++++++++++++++++++++++++------ crates/js/src/tests.rs | 33 +++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 81633d597d..f6d5a076c6 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -1207,16 +1207,31 @@ pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], do /// script can observe uncommitted writes. Read-only calculations avoid copying the whole form. pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() }; - if let Some(why) = std::iter::once(script).chain(doc_scripts.iter().map(String::as_str)).find_map(refuse) { + if let Some(why) = refuse(script) { return failed(why); } + // A document script too long or too deeply nested to run safely is skipped and reported, + // as one that throws is; the others and the script still run (#3940). + let mut skipped = Vec::new(); + let kept: Vec<(usize, &str)> = doc_scripts + .iter() + .enumerate() + .filter_map(|(i, d)| match refuse(d) { + Some(why) => { + skipped.push((i, why)); + None + } + None => Some((i, d.as_str())), + }) + .collect(); + let (kept, skipped) = (&kept, &skipped); #[cfg(not(target_arch = "wasm32"))] { std::thread::scope(|scope| { let spawned = std::thread::Builder::new() .name("pdfcraft-js".into()) .stack_size(SCRIPT_STACK) - .spawn_scoped(scope, || run_here(script, event, doc, fields, doc_scripts, limits)); + .spawn_scoped(scope, || run_here(script, event, doc, fields, kept, skipped, limits)); match spawned { Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), Err(e) => failed(format!("the script engine could not start: {e}")), @@ -1224,7 +1239,7 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { +/// How [`Outcome::doc_errors`] names a failing document script: by its 1-based place. +fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String { + format!("document script {}: {error}", index.saturating_add(1)) +} + +/// `doc_scripts` are the document scripts to run with their places among all of them; +/// `skipped` the ones refused before running, with the reason. +fn run_here( + script: &str, + event: &Event, + doc: &DocInfo, + fields: Arc>, + doc_scripts: &[(usize, &str)], + skipped: &[(usize, String)], + limits: Limits, +) -> Outcome { let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { Ok(ctx) => ctx, Err(e) => { @@ -1280,6 +1310,7 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); let result = (|| -> JsResult<()> { install(&mut ctx)?; let ev = event_object(&mut ctx, event); @@ -1287,9 +1318,9 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc() { let h = h.into_inner(); out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect(); diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index aec0631fdf..7124af1444 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -325,6 +325,33 @@ fn a_failing_document_script_does_not_stop_the_field_script() { assert!(go("event.value = 1;", &Event::field("Calculate", "total", "")).doc_errors.is_empty()); } +/// A document script refused before running (too long, or nested too deeply to run safely) is +/// skipped like one that throws: never run, reported in its place, the rest still run. +#[test] +fn a_document_script_too_big_to_run_is_skipped_and_reported() { + let docs = [ + "this.getField('qty').buttonSetIcon(null);".to_string(), + format!("var big = 1{};", "+1".repeat(MAX_SCRIPT_BYTES / 2)), + "function double(x) { return x * 2; }".to_string(), + format!("var deep = {}1{};", "(".repeat(10_000), ")".repeat(10_000)), + ]; + let o = run( + "event.value = double(getField('qty').value) + typeof big;", + &Event::field("Calculate", "total", ""), + &doc(), + &fields(), + &docs, + Limits::default(), + ); + assert_eq!((o.error.as_deref(), o.value.as_str()), (None, "8undefined")); + let places: Vec<&str> = o.doc_errors.iter().map(|e| e.split(':').next().unwrap_or_default()).collect(); + assert_eq!(places, ["document script 1", "document script 2", "document script 4"], "{:?}", o.doc_errors); + assert!(o.doc_errors[1].contains("too long") && o.doc_errors[2].contains("nested too deeply"), "{:?}", o.doc_errors); + // The field script's own limits still refuse it outright. + let o = run(&format!("1{}", "+1".repeat(MAX_SCRIPT_BYTES)), &Event::field("Calculate", "total", ""), &doc(), &fields(), &docs, Limits::default()); + assert!(o.error.is_some_and(|e| e.contains("too long")) && o.doc_errors.is_empty()); +} + #[test] fn errors_and_runaway_scripts_are_contained() { let o = go("event.value = nosuch.thing;", &Event::field("Calculate", "total", "keep")); @@ -364,9 +391,11 @@ fn deeply_nested_scripts_fail_instead_of_crashing() { let o = go(s, &Event::doc("Open")); assert!(o.error.is_some(), "{}…", &s[..20]); } - // A hostile document-level script is refused too, before the field script runs. + // A hostile document-level script is refused too: it never runs, and is reported, while + // the field script still does (#3940). let o = run("event.value = 'ran';", &Event::field("Calculate", "a", ""), &doc(), &fields(), &[hostile[0].clone()], Limits::default()); - assert!(o.error.is_some()); + assert_eq!((o.error, o.value.as_str()), (None, "ran")); + assert!(o.doc_errors.len() == 1 && o.doc_errors[0].contains("nested too deeply"), "{:?}", o.doc_errors); } #[test] From a7afaacbb9d313209e45ee9a527bdbcc6d0c3e60 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:49:43 +0800 Subject: [PATCH 25/47] M6: merged script output reports a document script's error once too (#3940) Review on #3985 (benandez): JsOutput::absorb reported a failing document script once, but append added every error again, so merging two outputs repeated it. pdfcraft_js now says whether an error is a document script's (is_doc_error, next to the doc_error format), and append skips one already reported; each field script error is still its own. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/js.rs | 27 ++++++++++++++++++++++++++- crates/js/src/lib.rs | 14 +++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 28e593531b..301b0592f8 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -44,7 +44,13 @@ impl JsOutput { self.alerts.extend(other.alerts); self.console.extend(other.console); self.requests.extend(other.requests); - self.errors.extend(other.errors); + // As in `absorb`: a document script's error, already reported, is not repeated; a field + // script's errors are each their own. + for e in other.errors { + if !(pdfcraft_js::is_doc_error(&e) && self.errors.contains(&e)) { + self.errors.push(e); + } + } } } @@ -455,6 +461,25 @@ mod review_tests { assert!(read <= 4 * n, "{read} characters read for {n} glyphs"); } + /// Review on #3985: merging two outputs reports a failing document script once, as one run + /// does, while each field script error stays. + #[test] + fn merged_output_reports_a_document_script_error_once() { + let run = |errors: &[&str]| JsOutput { errors: errors.iter().map(|e| e.to_string()).collect(), ..JsOutput::default() }; + let mut out = run(&["document script 2: TypeError: not a callable function", "ReferenceError: x is not defined"]); + out.append(run(&["document script 2: TypeError: not a callable function", "ReferenceError: x is not defined"])); + assert_eq!( + out.errors, + ["document script 2: TypeError: not a callable function", "ReferenceError: x is not defined", "ReferenceError: x is not defined"] + ); + // A field script's message that only looks like one (no script number) is still its own. + for look_alike in ["document script x: thrown by a field", "document script : thrown by a field"] { + out.append(run(&[look_alike])); + out.append(run(&[look_alike])); + assert_eq!(out.errors.iter().filter(|e| *e == look_alike).count(), 2, "{look_alike}"); + } + } + #[test] fn duplicate_field_names_diff_against_first_original() { let first = pdfcraft_js::FieldState::new("duplicate", pdfcraft_js::FieldType::Text, vec!["first".into()]); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index f6d5a076c6..cc835c79e6 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -1274,9 +1274,21 @@ impl HostHooks for CheckedCodeFromStrings { } } +/// The prefix of a document script's error ([`doc_error`]). +const DOC_ERROR_PREFIX: &str = "document script "; + /// How [`Outcome::doc_errors`] names a failing document script: by its 1-based place. fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String { - format!("document script {}: {error}", index.saturating_add(1)) + format!("{DOC_ERROR_PREFIX}{}: {error}", index.saturating_add(1)) +} + +/// Whether `error` is one of [`Outcome::doc_errors`]: a document script's failure, which repeats +/// each time the document scripts run again, unlike a field script's own error. +pub fn is_doc_error(error: &str) -> bool { + error + .strip_prefix(DOC_ERROR_PREFIX) + .and_then(|rest| rest.split_once(": ")) + .is_some_and(|(n, _)| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) } /// `doc_scripts` are the document scripts to run with their places among all of them; From 994ce630997b79aa1084f03307165e987b67d109 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:31:13 +0800 Subject: [PATCH 26/47] M6: a document script's error is told apart by where it came from, not its wording (#3940) Review follow-up on #3985: JsOutput deduplicated document-script errors by their text (document script N: ...), so a field script that threw the same words was dropped by append, and a document script's error was dropped by absorb when a field script had already reported that text. JsOutput now records which errors came from document scripts (doc_errors) and deduplicates only those; pdfcraft_js::is_doc_error, which guessed from the wording, is removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/engine/src/js.rs | 54 ++++++++++++++++++++++++------------- crates/js/src/lib.rs | 14 +--------- crates/ui-egui/src/js_ui.rs | 10 +++++-- 3 files changed, 45 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 301b0592f8..19b6c8cde8 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -19,6 +19,10 @@ pub struct JsOutput { pub requests: Vec, /// Script errors (as Acrobat's console shows them). pub errors: Vec, + /// Which of `errors` are document scripts' (#3940), told apart by where they came from and + /// not by their text, which a field script's error can share: each is reported once, however + /// often the document scripts run again. + pub doc_errors: Vec, } impl JsOutput { @@ -34,7 +38,8 @@ impl JsOutput { // The document scripts run before every field script, so a failing one would repeat // once per calculation; it is reported once. for e in &o.doc_errors { - if !self.errors.contains(e) { + if !self.doc_errors.contains(e) { + self.doc_errors.push(e.clone()); self.errors.push(e.clone()); } } @@ -45,11 +50,17 @@ impl JsOutput { self.console.extend(other.console); self.requests.extend(other.requests); // As in `absorb`: a document script's error, already reported, is not repeated; a field - // script's errors are each their own. + // script's errors are each their own, even one worded like a document script's. + let mut docs = other.doc_errors; for e in other.errors { - if !(pdfcraft_js::is_doc_error(&e) && self.errors.contains(&e)) { - self.errors.push(e); + if let Some(k) = docs.iter().position(|d| *d == e) { + docs.swap_remove(k); + if self.doc_errors.contains(&e) { + continue; + } + self.doc_errors.push(e.clone()); } + self.errors.push(e); } } } @@ -462,22 +473,29 @@ mod review_tests { } /// Review on #3985: merging two outputs reports a failing document script once, as one run - /// does, while each field script error stays. + /// does, while each field script error stays: told apart by where an error came from, so a + /// field script that throws a document script's very words is still reported. #[test] fn merged_output_reports_a_document_script_error_once() { - let run = |errors: &[&str]| JsOutput { errors: errors.iter().map(|e| e.to_string()).collect(), ..JsOutput::default() }; - let mut out = run(&["document script 2: TypeError: not a callable function", "ReferenceError: x is not defined"]); - out.append(run(&["document script 2: TypeError: not a callable function", "ReferenceError: x is not defined"])); - assert_eq!( - out.errors, - ["document script 2: TypeError: not a callable function", "ReferenceError: x is not defined", "ReferenceError: x is not defined"] - ); - // A field script's message that only looks like one (no script number) is still its own. - for look_alike in ["document script x: thrown by a field", "document script : thrown by a field"] { - out.append(run(&[look_alike])); - out.append(run(&[look_alike])); - assert_eq!(out.errors.iter().filter(|e| *e == look_alike).count(), 2, "{look_alike}"); - } + let doc = "document script 2: TypeError: not a callable function"; + let field = "ReferenceError: x is not defined"; + let run = |error: &str| { + let mut out = JsOutput::default(); + out.absorb(&Outcome { error: Some(error.into()), doc_errors: vec![doc.into()], ..Outcome::default() }); + out + }; + let mut out = run(field); + out.append(run(field)); + assert_eq!(out.errors, [field, doc, field]); + // CodeRabbit on #3985: a field script that throws the document script's text. + out.append(run(doc)); + assert_eq!(out.errors, [field, doc, field, doc]); + let alone = run(doc); + assert_eq!(alone.errors, [doc, doc], "the field's error and the document script's"); + let count = |o: &JsOutput| o.errors.iter().filter(|e| *e == doc).count(); + let mut merged = run(field); + merged.append(alone); + assert_eq!(count(&merged), 2, "the field's copy stays; the document script's is listed once"); } #[test] diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index cc835c79e6..f6d5a076c6 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -1274,21 +1274,9 @@ impl HostHooks for CheckedCodeFromStrings { } } -/// The prefix of a document script's error ([`doc_error`]). -const DOC_ERROR_PREFIX: &str = "document script "; - /// How [`Outcome::doc_errors`] names a failing document script: by its 1-based place. fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String { - format!("{DOC_ERROR_PREFIX}{}: {error}", index.saturating_add(1)) -} - -/// Whether `error` is one of [`Outcome::doc_errors`]: a document script's failure, which repeats -/// each time the document scripts run again, unlike a field script's own error. -pub fn is_doc_error(error: &str) -> bool { - error - .strip_prefix(DOC_ERROR_PREFIX) - .and_then(|rest| rest.split_once(": ")) - .is_some_and(|(n, _)| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + format!("document script {}: {error}", index.saturating_add(1)) } /// `doc_scripts` are the document scripts to run with their places among all of them; diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 7bc3dd0444..f5ec01625e 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -88,7 +88,13 @@ impl PdfCraftApp { { self.views[i].document_changed(&info); } - let out = JsOutput { alerts: o.alerts, console: o.console, requests: o.requests, errors: o.error.into_iter().collect() }; + let out = JsOutput { + alerts: o.alerts, + console: o.console, + requests: o.requests, + errors: o.error.into_iter().collect(), + doc_errors: Vec::new(), + }; self.handle_js(id, out); } Err(e) => self.notify_fmt("{field}: {e}", &[("field", field), ("e", &e.to_string())]), @@ -128,7 +134,7 @@ impl PdfCraftApp { self.views[i].document_changed(&info); } let (error, result) = (o.error.clone(), o.result.clone()); - let out = JsOutput { alerts: o.alerts, console: o.console, requests: o.requests, errors: Vec::new() }; + let out = JsOutput { alerts: o.alerts, console: o.console, requests: o.requests, errors: Vec::new(), doc_errors: Vec::new() }; self.handle_js(id, out); match error { Some(e) => self.js_console.log.push(format!("Error: {e}")), From 441d9005e3e67f1eea22a8e6fdf6248e94c3d360 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:08:42 -0400 Subject: [PATCH 27/47] M12: XFA text is measured at its standard-14 font's own widths --- crates/xfa/src/tests.rs | 40 ++++++++++++++++++++++++++++++++ crates/xfa/src/text.rs | 44 +++++++++++++++++++----------------- parity/acrobat-features.toml | 4 ++-- 3 files changed, 65 insertions(+), 23 deletions(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b177..4e28351b16 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2832,3 +2832,43 @@ fn long_names_repeated_by_an_index_are_capped() { let data = build_data(&doc, &fields); assert!(data.len() < 2_000_000, "{} bytes of data built", data.len()); } + +/// Text is measured in the standard-14 face it is drawn in, at the widths ISO 32000-2 Annex D +/// gives: bold and Times are no longer Helvetica scaled, and the horizontal scale still applies. +#[test] +fn faces_measure_text_at_their_standard_14_widths() { + use pdfcraft_fonts::Std14; + let face = |family: text::Family, bold: bool, italic: bool| text::Face { + family, + bold, + italic, + size: 10.0, + color: [0.0; 3], + underline: false, + h_scale: 1.0, + }; + let sample = "Mill Wax 0123 Departure Date"; + for (family, bold, italic, std14) in [ + (text::Family::Helvetica, false, false, Std14::Helvetica), + (text::Family::Helvetica, true, false, Std14::HelveticaBold), + (text::Family::Helvetica, true, true, Std14::HelveticaBoldOblique), + (text::Family::Times, false, false, Std14::TimesRoman), + (text::Family::Times, true, false, Std14::TimesBold), + (text::Family::Times, false, true, Std14::TimesItalic), + (text::Family::Times, true, true, Std14::TimesBoldItalic), + (text::Family::Courier, true, false, Std14::CourierBold), + ] { + let f = face(family, bold, italic); + assert!( + (f.width(sample) - std14.text_width(sample, 10.0)).abs() < 1e-9, + "{std14:?}: {} vs {}", + f.width(sample), + std14.text_width(sample, 10.0) + ); + assert_eq!(f.family.std14(bold, italic), std14); + assert_eq!(f.family.base_font(bold, italic), std14.base_font()); + } + // A condensed face is narrower by its scale. + let condensed = text::Face { h_scale: 0.8, ..face(text::Family::Times, true, false) }; + assert!((condensed.width(sample) - 0.8 * Std14::TimesBold.text_width(sample, 10.0)).abs() < 1e-9); +} diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index 7e83212680..e8d0fdba3c 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -3,6 +3,7 @@ //! bundled here). use crate::model::{Font, HAlign, Para, Rect, RichText, Run}; +use pdfcraft_fonts::Std14; /// The standard-14 family a typeface maps to. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -29,22 +30,27 @@ impl Family { } } + /// The standard-14 font this family draws in, as `bold` and `italic` ask. + pub fn std14(self, bold: bool, italic: bool) -> Std14 { + match (self, bold, italic) { + (Family::Helvetica, false, false) => Std14::Helvetica, + (Family::Helvetica, true, false) => Std14::HelveticaBold, + (Family::Helvetica, false, true) => Std14::HelveticaOblique, + (Family::Helvetica, true, true) => Std14::HelveticaBoldOblique, + (Family::Courier, false, false) => Std14::Courier, + (Family::Courier, true, false) => Std14::CourierBold, + (Family::Courier, false, true) => Std14::CourierOblique, + (Family::Courier, true, true) => Std14::CourierBoldOblique, + (Family::Times, false, false) => Std14::TimesRoman, + (Family::Times, true, false) => Std14::TimesBold, + (Family::Times, false, true) => Std14::TimesItalic, + (Family::Times, true, true) => Std14::TimesBoldItalic, + } + } + /// The PDF base font name for a face. pub fn base_font(self, bold: bool, italic: bool) -> &'static str { - match (self, bold, italic) { - (Family::Helvetica, false, false) => "Helvetica", - (Family::Helvetica, true, false) => "Helvetica-Bold", - (Family::Helvetica, false, true) => "Helvetica-Oblique", - (Family::Helvetica, true, true) => "Helvetica-BoldOblique", - (Family::Courier, false, false) => "Courier", - (Family::Courier, true, false) => "Courier-Bold", - (Family::Courier, false, true) => "Courier-Oblique", - (Family::Courier, true, true) => "Courier-BoldOblique", - (Family::Times, false, false) => "Times-Roman", - (Family::Times, true, false) => "Times-Bold", - (Family::Times, false, true) => "Times-Italic", - (Family::Times, true, true) => "Times-BoldItalic", - } + self.std14(bold, italic).base_font() } /// The resource name used in content streams and `/DA` strings (Acrobat's conventions). @@ -121,16 +127,12 @@ impl Face { self.family.resource_name(self.bold, self.italic) } - /// Advance of `s` at this face's size. + /// Advance of `s` at this face's size: the standard-14 font it is drawn in, at that font's + /// own widths (ISO 32000-2 Annex D) and encoded as it is written, then the horizontal scale. pub fn width(&self, s: &str) -> f64 { #[cfg(test)] MEASURED.with(|m| m.set(m.get().saturating_add(s.len() as u64))); - let designed = match self.family { - Family::Courier => s.chars().count() as f64 * 0.6 * self.size, - Family::Helvetica => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 1.04 } else { 1.0 }, - Family::Times => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 0.96 } else { 0.92 }, - }; - designed * self.h_scale + self.family.std14(self.bold, self.italic).text_width(s, self.size) * self.h_scale } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6d950a71ca..03fc66f499 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5249,8 +5249,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn"] -notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths"] +notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From afcc54fddc6e1f7bcdaa550bed7cb3ebd8bb0571 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:53:02 +0200 Subject: [PATCH 28/47] Omit minus sign when FormCalc Format() rounds to zero Found while testing crates/js/src/formcalc.rs:2328-2330 ("format" number-picture branch, `if num < 0.0 { text = format!("-{text}") }`) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/js/src/formcalc.rs | 3 ++- crates/js/src/tests.rs | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 2348ce2206..50191f1f07 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -2325,10 +2325,11 @@ impl Interp<'_> { } text = if frac.is_empty() { g } else { format!("{g}.{frac}") }; } + let nonzero = text.chars().any(|c| ('1'..='9').contains(&c)); if pic.contains('$') { text = format!("${text}"); } - if num < 0.0 { + if num < 0.0 && nonzero { text = format!("-{text}"); } Val::Str(text) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index b2e866faef..c7419be475 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -805,6 +805,14 @@ mod formcalc { o.result.unwrap_or_default() } + /// A negative value that rounds to zero under the picture prints without a minus sign. + #[test] + fn format_does_not_print_negative_zero() { + assert_eq!(calc(r#"Format("9.9", -0.04)"#), "0.0"); + assert_eq!(calc(r#"Format("99", -0.2)"#), "00"); + assert_eq!(calc(r#"Format("9.9", -1.5)"#), "-1.5"); + } + /// Assigning one long value to many fields at once stops at the run's byte budget and is /// charged per copy, and a reset list names each field once. #[test] From 0b8758eda34185538c1f8a4bcedcc9b1bfd24461 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:16:40 -0400 Subject: [PATCH 29/47] M12: links in XFA rich text become clickable links --- crates/automation/tests/it/automation.rs | 27 +++++ crates/xfa/src/model.rs | 2 + crates/xfa/src/parse.rs | 13 ++- crates/xfa/src/pdf.rs | 102 ++++++++++++++++++- crates/xfa/src/tests.rs | 121 +++++++++++++++++++++++ crates/xfa/src/text.rs | 17 ++-- parity/acrobat-features.toml | 4 +- 7 files changed, 274 insertions(+), 12 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9fd5c4269a..22bb5ee4a9 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6500,6 +6500,33 @@ fn xfa_rich_text_max_length_is_reported_and_enforced_through_tools() { assert_eq!(family["value"], "éàöüñ"); } +/// A link in a dynamic XFA form's rich text is a link on the laid-out page, as link_list reports +/// it, and stays one after a save and a fresh open. +#[test] +fn links_in_xfa_rich_text_are_listed_through_tools() { + let dir = workdir("xfa-links"); + let xdp = r##" +"##; + std::fs::write(dir.join("links.pdf"), pdfcraft_xfa::fixtures::shell(xdp)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "links.pdf" }))["doc"].as_u64().unwrap(); + let urls = |a: &mut Automation, doc: u64| -> Vec { + let listed = ok(a, "link_list", json!({ "doc": doc })); + let links = listed["links"].as_array().cloned().unwrap_or_default(); + links.iter().filter_map(|l| l["url"].as_str().map(str::to_string)).collect() + }; + assert_eq!(urls(&mut a, doc), ["mailto:forms@example.com"]); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "name": "Ada" } })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" })); + ok(&mut a, "doc_close", json!({ "doc": doc })); + let doc = ok(&mut a, "doc_open", json!({ "path": "saved.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(urls(&mut a, doc), ["mailto:forms@example.com"], "one link after a save and a fresh open, not two"); +} + #[test] fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() { let dir = workdir("xfa"); diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index b60180b7fa..843d78f34b 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -633,6 +633,8 @@ pub struct Run { pub h_scale: Option, /// A floating field embedded in the text (`xfa:embed="#id"`), shown by its value. pub embed: Option, + /// Where the text links to (``), as the template writes it. + pub link: Option, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index fc9ad1a245..13029bf05d 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -201,7 +201,7 @@ fn parse_common(n: XmlNode) -> Common { } /// Rich text: XHTML `body > p > span…`. `br` starts a new paragraph; `xfa:embed` spans become -/// embedded fields. +/// embedded fields; the text of an `` keeps where it links to. fn parse_rich(body: XmlNode) -> RichText { #[derive(Clone, Default)] struct Style { @@ -210,6 +210,8 @@ fn parse_rich(body: XmlNode) -> RichText { underline: Option, size: Option, h_scale: Option, + /// The `href` of the `` the text is in. + link: Option, } /// `base` with the CSS declarations in `css` applied: weight, style, underline, size and /// `xfa-font-horizontal-scale`. Anything else is ignored. @@ -254,6 +256,7 @@ fn parse_rich(body: XmlNode) -> RichText { size: style.size, h_scale: style.h_scale, embed: None, + link: style.link.clone(), }, ); } @@ -285,6 +288,13 @@ fn parse_rich(body: XmlNode) -> RichText { "b" => st.bold = Some(true), "i" => st.italic = Some(true), "u" => st.underline = Some(true), + // A link's text keeps where it points; an `` without one (a named + // anchor) leaves the text as it was. + "a" => { + if let Some(href) = c.attribute("href").map(str::trim).filter(|h| !h.is_empty()) { + st.link = Some(href.to_string()); + } + } _ => {} } let embed = c.attributes().find(|a| a.name() == "embed").map(|a| a.value().trim_start_matches('#').to_string()); @@ -300,6 +310,7 @@ fn parse_rich(body: XmlNode) -> RichText { size: st.size, h_scale: st.h_scale, embed: Some(e), + link: st.link.clone(), }, ); } diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 8e0c47d888..4ca32c80ac 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -10,7 +10,7 @@ use pdfcraft_fonts::{literal, win_ansi}; use crate::XfaError; use crate::layout::{Action, BorderShape, Form, Item, Page, Widget, WidgetKind}; use crate::model::Rect; -use crate::text::{Face, Family}; +use crate::text::{Face, Family, Span}; /// Private key on generated fields: the template's SOM path, so data can be put back. pub const SOM_KEY: &[u8] = b"PCSom"; @@ -574,8 +574,8 @@ impl Emitter<'_> { } /// Write one laid-out page under `pages_ref`: its fills, lines, text and pictures in a - /// content stream, the fonts and images it uses as resources, and its widgets as - /// annotations. Returns the page object. + /// content stream, the fonts and images it uses as resources, and its widgets and the links + /// in its text as annotations. Returns the page object. fn page(&mut self, page: &Page, pages_ref: ObjRef) -> Result { let page_ref = self.doc.add(Object::Dict(Dict::new())); let mut content: Vec = Vec::new(); @@ -583,6 +583,8 @@ impl Emitter<'_> { let mut xobjects = Dict::new(); let mut annots = Vec::new(); let h = page.height; + // The link the text before ended in, which a next piece of it on the line widens. + let mut last_link: Option = None; for (i, item) in page.items.iter().enumerate() { match item { Item::Fill { rect, color } => { @@ -629,6 +631,9 @@ impl Emitter<'_> { ); content.extend(literal(&win_ansi(&s.text))); content.extend_from_slice(format!(" Tj{close}\n").as_bytes()); + if let Some(r) = self.text_link(s, h, page_ref, &mut last_link)? { + annots.push(Object::Ref(r)); + } } Item::Image { rect, data, content_type } => { let Some(r) = self.image_xobject(data, content_type) else { continue }; @@ -668,6 +673,97 @@ impl Emitter<'_> { self.doc.set(page_ref, Object::Dict(pd)); Ok(page_ref) } + + /// A link annotation over span `s` (on a page `page_height` tall), when its text links + /// somewhere: over the text it draws, without the spaces around it, and opening its address. + /// A span that goes on the link `last` ended in, on the same line, widens that annotation + /// instead (a link whose text changes style partway is one link). An address PdfCraft + /// wouldn't open from a document leaves the text as plain text, with one warning for the form. + fn text_link(&mut self, s: &Span, page_height: f64, page_ref: ObjRef, last: &mut Option) -> Result, XfaError> { + let piece = self.link_piece(s, page_height); + let Some((target, rect)) = piece else { + *last = None; + return Ok(None); + }; + if let Some(prev) = last.as_mut() + && prev.uri == target + && (prev.baseline - s.baseline).abs() < 0.01 + && (-0.01..=s.face.size).contains(&(rect[0] - prev.rect[2])) + { + prev.rect = [prev.rect[0].min(rect[0]), prev.rect[1].min(rect[1]), prev.rect[2].max(rect[2]), prev.rect[3].max(rect[3])]; + let r = prev.rect; + self.doc.update_dict(prev.annot, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; + return Ok(None); + } + let mut action = Dict::new(); + action.set(b"S".to_vec(), Object::name("URI")); + action.set(b"URI".to_vec(), Object::String(PdfString::literal(target.as_bytes().to_vec()))); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Link")); + d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())); + d.set(b"P".to_vec(), Object::Ref(page_ref)); + d.set(b"F".to_vec(), Object::Int(4)); + d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(0)])); + d.set(b"A".to_vec(), Object::Dict(action)); + let annot = self.doc.add(Object::Dict(d)); + *last = Some(LinkPiece { annot, uri: target, baseline: s.baseline, rect }); + Ok(Some(annot)) + } + + /// Where span `s` links to and the rectangle (PDF user space, on a page `page_height` tall) + /// over the text it draws, without the spaces around it. `None` for text that links nowhere, + /// draws nothing, or links to an address PdfCraft wouldn't open from a document, which is + /// warned about once for the form. + fn link_piece(&mut self, s: &Span, page_height: f64) -> Option<(String, [f64; 4])> { + let href = s.link.as_deref()?; + let shown = s.text.trim(); + if shown.is_empty() { + return None; + } + let Some(target) = link_target(href) else { + if !self.warnings.iter().any(|w| w.starts_with(LINK_LEFT_OUT)) { + self.warnings.push(format!("{LINK_LEFT_OUT}: PdfCraft makes links only of web (http, https) and email (mailto) addresses")); + } + return None; + }; + let lead = s.text.len() - s.text.trim_start().len(); + let x = s.x + s.face.width(s.text.get(..lead).unwrap_or_default()); + let w = s.face.width(shown); + // The text's ascent and descent, as the layout measures a span's extent. + let rect = pdf_rect(Rect::new(x, s.baseline - s.face.size * 0.8, w, s.face.size), page_height); + (rect.iter().all(|v| v.is_finite()) && w > 0.0).then(|| (target.to_string(), rect)) + } +} + +/// The link annotation a page's text last ended in: the next piece of the same link on the same +/// line widens it. +struct LinkPiece { + annot: ObjRef, + uri: String, + baseline: f64, + rect: [f64; 4], +} + +/// How the layout warning about a link left out starts. +const LINK_LEFT_OUT: &str = "a link in the form's text was left as plain text"; + +/// The longest link address kept, in characters: as long as PdfCraft opens from a document. +const MAX_LINK: usize = 2048; + +/// `href` as the address of a link PdfCraft makes from a form's text: a web (http, https) or +/// email (mailto) address, trimmed, of at most [`MAX_LINK`] characters and without control +/// characters. `None` for anything else (scripts, files, data, relative paths). Opening it still +/// goes through the app's own checks and prompt. +fn link_target(href: &str) -> Option<&str> { + let href = href.trim(); + if href.is_empty() || href.chars().count() > MAX_LINK || href.chars().any(char::is_control) { + return None; + } + let (scheme, rest) = href.split_once(':')?; + let web = scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"); + let ok = if web { rest.starts_with("//") && rest.len() > 2 } else { scheme.eq_ignore_ascii_case("mailto") && !rest.is_empty() }; + ok.then_some(href) } /// Replace the document's pages with the laid-out form and add its fields to the AcroForm. The diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b177..665bf82e50 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -350,6 +350,127 @@ fn form_text_the_standard_fonts_cannot_draw_is_reported() { assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts"))); } +/// A template whose one draw holds `body` as rich text, in a box `w` wide. +fn rich_draw_form(w: &str, body: &str) -> String { + format!( + r##" +"## + ) +} + +/// The link annotations on the pages of `doc`: each one's URI and rectangle. +fn text_links(doc: &Document) -> Vec<(String, [f64; 4])> { + let catalog = doc.get(doc.root().unwrap()); + let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap()); + let kids = doc.resolve(pages.as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().clone(); + let mut out = Vec::new(); + for page in &kids { + let page = doc.resolve(page); + let Some(annots) = page.as_dict().unwrap().get(b"Annots").map(|a| doc.resolve(a).as_array().unwrap().clone()) else { continue }; + for a in &annots { + let a = doc.resolve(a); + let d = a.as_dict().unwrap(); + if d.name(b"Subtype") != Some(&b"Link"[..]) { + continue; + } + let action = doc.resolve(d.get(b"A").unwrap()); + let uri = + action.as_dict().unwrap().get(b"URI").and_then(Object::as_string).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap(); + let r: Vec = doc.resolve(d.get(b"Rect").unwrap()).as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + out.push((uri, [r[0], r[1], r[2], r[3]])); + } + } + out +} + +/// An `` in rich text keeps where it links to on every run of its text, styled or not; +/// the text around it and a named anchor without an address link nowhere. +#[test] +fn rich_text_links_keep_their_address() { + let (tpl, _) = crate::parse(&rich_draw_form( + "4in", + r#"Write to forms@example.com or see the top."#, + )) + .unwrap(); + let Node::Draw(d) = &tpl.root.children[0] else { panic!("the draw") }; + let Value::Rich(rich) = &d.value else { panic!("rich text") }; + let runs: Vec<(&str, Option<&str>)> = rich.paragraphs.iter().flat_map(|p| &p.runs).map(|r| (r.text.as_str(), r.link.as_deref())).collect(); + assert_eq!( + runs, + [ + ("Write to ", None), + ("forms@", Some("mailto:forms@example.com")), + ("example", Some("mailto:forms@example.com")), + (".com", Some("mailto:forms@example.com")), + (" or see ", None), + ("the top", None), + (".", None), + ] + ); +} + +/// A link's text becomes a link annotation on the page, one over each line it takes when it +/// wraps, each over its own text; a second layout replaces them rather than adding more. +#[test] +fn links_in_rich_text_become_link_annotations_one_per_line() { + let body = r#"See the help pages for this form, which explain every block first."#; + let mut doc = Document::open(Arc::new(shell(&rich_draw_form("2in", body)))).unwrap(); + let report = render_into(&mut doc).unwrap(); + assert!(report.warnings.iter().all(|w| !w.contains("link")), "{:?}", report.warnings); + let links = text_links(&doc); + assert!(links.len() >= 2, "the link wraps onto at least two lines: {links:?}"); + assert!(links.iter().all(|(uri, _)| uri == "https://example.com/forms/help")); + // One per line: each below the one before, all inside the 2-inch box (from x = 36pt). + for pair in links.windows(2) { + assert!(pair[1].1[3] <= pair[0].1[1] + 0.01, "a line below the one before: {pair:?}"); + } + for (_, r) in &links { + assert!(r[0] >= 36.0 - 0.01 && r[2] <= 36.0 + 144.0 + 0.01 && r[2] > r[0] && r[3] > r[1], "{r:?}"); + } + // The first line's link starts after "See ", not at the box's edge. + assert!(links[0].1[0] > 36.0 + 5.0, "{:?}", links[0].1); + // A second layout replaces the pages, and their links with them. + let tpl = crate::script::template_of(&doc).unwrap(); + rerender(&mut doc, &tpl).unwrap(); + assert_eq!(text_links(&doc), links, "laid out again: the same links, not twice as many"); + // Saved and opened again, they are still there. + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(text_links(&Document::open(Arc::new(bytes)).unwrap()), links); + + // A link whose text changes style partway is one link on its line, over all its text. + let mut doc = + Document::open(Arc::new(shell(&rich_draw_form("4in", r#"Write to forms@example.com today."#)))) + .unwrap(); + render_into(&mut doc).unwrap(); + let links = text_links(&doc); + assert_eq!(links.len(), 1, "{links:?}"); + let arial = Font { typeface: "Arial".into(), size: 10.0, ..Default::default() }; + let plain = crate::text::Face::of(&arial); + let bold = crate::text::Face::of(&Font { bold: true, ..arial.clone() }); + let width = plain.width("forms@") + bold.width("example") + plain.width(".com"); + let r = links[0].1; + assert!((r[2] - r[0] - width).abs() < 0.01, "over the whole address: {} against {width}", r[2] - r[0]); +} + +/// Only web and email addresses become links. Scripts, files, data, relative paths and empty +/// or overlong addresses stay plain text (still drawn), with one warning for the form. +#[test] +fn links_to_anything_but_web_and_email_addresses_stay_plain_text() { + let long = format!("https://example.com/{}", "a".repeat(2100)); + let body = format!( + r#"one two three four five six seven"# + ); + let mut doc = Document::open(Arc::new(shell(&rich_draw_form("7in", &body)))).unwrap(); + let report = render_into(&mut doc).unwrap(); + assert_eq!(text_links(&doc).into_iter().map(|(u, _)| u).collect::>(), ["MailTo:a@example.com"]); + let warned: Vec<&String> = report.warnings.iter().filter(|w| w.contains("link")).collect(); + assert_eq!(warned.len(), 1, "one warning for the form: {:?}", report.warnings); + assert!(warned[0].contains("http, https") && warned[0].contains("mailto"), "{warned:?}"); +} + #[test] fn documents_without_xfa_are_not_touched() { let mut doc = Document::new_empty(); diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index 7e83212680..99abda281e 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -153,6 +153,8 @@ pub struct Span { pub baseline: f64, pub text: String, pub face: Face, + /// Where the text links to (its run's ``), as the template writes it. + pub link: Option, } /// A laid-out block of text. @@ -168,6 +170,8 @@ struct Word { text: String, face: Face, width: f64, + /// The link of the run the word came from. + link: Option, } /// The width of `w` without the spaces it ends with: what it takes at the end of a line, where @@ -224,12 +228,12 @@ fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str, usize) -> String, embed cur.push(ch); if ch == ' ' { let width = face.width(&cur); - out.push(Word { text: std::mem::take(&mut cur), face: face.clone(), width }); + out.push(Word { text: std::mem::take(&mut cur), face: face.clone(), width, link: run.link.clone() }); } } if !cur.is_empty() { let width = face.width(&cur); - out.push(Word { text: cur, face, width }); + out.push(Word { text: cur, face, width, link: run.link.clone() }); } } out @@ -277,7 +281,7 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn if advance > avail - indent(line.first) && piece.len() > ch.len_utf8() { piece.pop(); let width = w.face.width(&piece); - line.words.push(Word { text: std::mem::take(&mut piece), face: w.face.clone(), width }); + line.words.push(Word { text: std::mem::take(&mut piece), face: w.face.clone(), width, link: w.link.clone() }); line.width += width; line.size = line.size.max(w.face.size); let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false }); @@ -290,7 +294,7 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn let width = w.face.width(&piece); line.size = line.size.max(w.face.size); line.width += width; - line.words.push(Word { text: piece, face: w.face.clone(), width }); + line.words.push(Word { text: piece, face: w.face.clone(), width, link: w.link.clone() }); } first_in_para = false; continue; @@ -335,16 +339,17 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn }; // Baseline: ascent sits about 0.8 of the size below the line top for these faces. let baseline = y + lh - (lh - line.size * 0.8).max(0.0) / 2.0 - line.size * 0.08; + // Words of one face and one link make one span, so a link's text is a span of its own. let mut run: Option = None; for w in line.words { match &mut run { - Some(s) if s.face == w.face => s.text.push_str(&w.text), + Some(s) if s.face == w.face && s.link == w.link => s.text.push_str(&w.text), _ => { if let Some(s) = run.take() { x += s.face.width(&s.text); spans.push(s); } - run = Some(Span { x, baseline, text: w.text, face: w.face }); + run = Some(Span { x, baseline, text: w.text, face: w.face, link: w.link }); } } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c5..20b0ff59de 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5249,8 +5249,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn"] -notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools"] +notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From 1c4a481ea30850f778674b337dc57a6ee022bfe1 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:18:39 -0400 Subject: [PATCH 30/47] M12: round XFA check buttons draw a round background and border --- crates/engine/src/tests.rs | 29 ++++++++++++++ crates/forms/src/appearance.rs | 71 +++++++++++++++++++++++++++++++++- crates/forms/src/tests.rs | 43 ++++++++++++++++++++ crates/xfa/src/pdf.rs | 10 +++++ crates/xfa/src/tests.rs | 46 ++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 6 files changed, 200 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1c..df0ed33a97 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4675,6 +4675,35 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { ); } +/// A round XFA check button opens drawn round: the appearance its laid-out widget gets draws a +/// circle, the on state with its mark in it, while a square one keeps its box. +#[test] +fn round_xfa_check_buttons_open_drawn_round() { + let xdp = r##" +"##; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + let cos = s.get(id).unwrap().editor.as_ref().unwrap().cos.clone(); + let all = pdfcraft_forms::fields(&cos); + // The decoded appearance of `name`'s widget in `state`. + let drawn = |name: &str, state: &[u8]| { + let w = all.iter().find(|f| f.name.ends_with(name)).unwrap_or_else(|| panic!("no field {name}")).widgets[0].obj; + let wd = cos.get(w).as_dict().cloned().unwrap(); + let n = cos.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap()); + let r = n.as_dict().unwrap().reference(state).unwrap_or_else(|| panic!("{name} has no {} state", String::from_utf8_lossy(state))); + let pdfcraft_cos::Object::Stream(st) = &*cos.get(r) else { panic!("a stream") }; + String::from_utf8_lossy(&st.decoded().unwrap()).into_owned() + }; + for state in [&b"Off"[..], b"1"] { + let round = drawn("round", state); + assert!(round.contains(" c S") && !round.contains(" re S"), "the round one's border is a circle: {round}"); + assert!(drawn("square", state).contains(" re S"), "the square one keeps its box"); + } +} + #[test] fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))); diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 3b6f6f20d6..7f55e192ea 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -277,10 +277,14 @@ fn stroke_op(fill: &str) -> String { } } -/// Background and border from `/MK` and `/BS`; returns (content, border width). +/// Background and border from `/MK` and `/BS`; returns (content, border width). A round XFA +/// check button's ([`is_round`]) are a circle inside the widget instead of its rectangle. fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); let mut c = String::new(); + if is_round(doc, wd) { + return round_frame(doc, wd, mk.as_ref(), w, h); + } if let Some(bg) = color_array(doc, mk.as_ref(), b"BG") { c.push_str(&format!("{bg}\n0 0 {} {} re f\n", n(w), n(h))); } @@ -346,6 +350,71 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { (c, 0.0) } +/// Whether `wd` is a check box or radio button PdfCraft laid out for a round XFA check button +/// (`/PCXfaRound true`, written by the XFA layout). +fn is_round(doc: &Document, wd: &Dict) -> bool { + matches!(wd.get(b"PCXfaRound").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) +} + +/// [`frame`] for a round check button: the `/MK /BG` background as a disc and the `/MK /BC` +/// border as a circle, both centred in the `w` × `h` box and as wide as its shorter side. A +/// dashed border stays dashed; any other style is drawn solid. Returns the border width. +fn round_frame(doc: &Document, wd: &Dict, mk: Option<&Dict>, w: f64, h: f64) -> (String, f64) { + let mut c = String::new(); + let (cx, cy, r) = (w / 2.0, h / 2.0, w.min(h).max(0.0) / 2.0); + if let Some(bg) = color_array(doc, mk, b"BG") { + c.push_str(&format!("{bg}\n{}f\n", circle_path(cx, cy, r))); + } + let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); + let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0); + match color_array(doc, mk, b"BC") { + Some(bc) if bw > 0.0 => { + let dashed = bs.as_ref().and_then(|b| b.name(b"S")) == Some(b"D"); + // The stroke is centred on the path: inset by half its width, it stays inside. + let path = circle_path(cx, cy, (r - bw / 2.0).max(0.0)); + c.push_str(&format!("{}\n{} w\n{}{path}S\n[] 0 d\n", stroke_op(&bc), n(bw), if dashed { "[3] 0 d\n" } else { "" })); + (c, bw) + } + _ => (c, 0.0), + } +} + +/// A closed circle of radius `r` around (`cx`, `cy`) as four Bézier arcs, ending with a space +/// so a paint operator can follow. +fn circle_path(cx: f64, cy: f64, r: f64) -> String { + // The control-point distance for a quarter circle. + let k = 0.5523 * r; + format!( + "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c ", + n(cx + r), + n(cy), + n(cx + r), + n(cy + k), + n(cx + k), + n(cy + r), + n(cx), + n(cy + r), + n(cx - k), + n(cy + r), + n(cx - r), + n(cy + k), + n(cx - r), + n(cy), + n(cx - r), + n(cy - k), + n(cx - k), + n(cy - r), + n(cx), + n(cy - r), + n(cx + k), + n(cy - r), + n(cx + r), + n(cy - k), + n(cx + r), + n(cy) + ) +} + /// The width [`frame`] insets the content by: the `/BS` border width when the widget has a /// border colour, doubled for beveled and inset borders, and 0 without a border. fn border_width(doc: &Document, wd: &Dict) -> f64 { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ea683bb67f..fb48bd39e7 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -387,6 +387,49 @@ fn opt_export_values_select_button_states() { assert!(err.to_string().contains("options: Post, Pick-up"), "{err}"); } +/// A check box laid out for a round XFA check button (`/PCXfaRound true`) draws its background +/// and border as a circle inside the widget, inset by half the border so the stroke stays in; +/// one without the key keeps its rectangle. +#[test] +fn a_round_xfa_check_button_draws_a_round_background_and_border() { + let widget = |name: &str, extra: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Btn /T ({name}) /Rect [50 700 70 720] /P 3 0 R /MK << /BG [0.8 0.85 1] /BC [1 0 0] >> /BS << /W 1 /S /S >>{extra} >>" + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R] >>".into(), + widget("round", " /PCXfaRound true"), + widget("square", ""), + widget("dashed", " /PCXfaRound true /BS << /W 2 /S /D >>").replace("/BS << /W 1 /S /S >>", ""), + ]; + let mut doc = document(&objs); + for name in ["round", "square", "dashed"] { + redraw_field(&mut doc, name).unwrap(); + } + // The Off state: only the frame. + let off = |name: &str| { + let all = fields(&doc); + let w = field(&all, name).widgets[0].obj; + let wd = doc.get(w).as_dict().cloned().unwrap(); + let n = doc.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap()); + let r = n.as_dict().unwrap().reference(b"Off").expect("an Off appearance"); + let Object::Stream(s) = &*doc.get(r) else { panic!("Off is a stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }; + let round = off("round"); + assert!(!round.contains(" re "), "no rectangle: {round}"); + assert!(round.contains("0.8 0.85 1 rg\n20 10 m"), "the background is a disc as wide as the box: {round}"); + assert!(round.contains("1 0 0 RG\n1 w\n19.5 10 m") && round.contains(" c S"), "the border is a circle inset by half its width: {round}"); + let square = off("square"); + assert!(square.contains("0 0 20 20 re f") && square.contains("re S"), "a square box keeps its rectangle: {square}"); + let dashed = off("dashed"); + assert!(dashed.contains("[3] 0 d\n19 10 m"), "a dashed round border stays dashed: {dashed}"); +} + /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an /// empty value clears it. #[test] diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 8e0c47d888..c51879dfa3 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -160,6 +160,11 @@ pub struct Written { /// layer reads this key). pub const IMAGE_FIELD_KEY: &[u8] = b"PCXfaImage"; +/// On a check box or radio button generated for a round XFA check button (`shape="round"`): its +/// background and border are drawn as a circle, not the widget's rectangle (the forms layer +/// reads this key). +pub const ROUND_KEY: &[u8] = b"PCXfaRound"; + /// On an image XObject written for a picture: the SHA-256 (hex) of the picture's bytes. A /// re-layout (after every script event) finds the XObjects on the pages it replaces by this, /// and reuses them instead of decoding and embedding the same picture again. Nothing is kept @@ -244,6 +249,8 @@ impl Emitter<'_> { format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes() } + /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with + /// its border, background and kind, and return the annotation's object. fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result, XfaError> { let r = w.rect; let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y); @@ -271,6 +278,9 @@ impl Emitter<'_> { bs.set(b"W".to_vec(), Object::Real(if w.border.shape == BorderShape::None { 0.0 } else { w.border.width.clamp(0.1, 12.0) })); bs.set(b"S".to_vec(), Object::name(if w.border.shape == BorderShape::Underline { "U" } else { "S" })); d.set(b"BS".to_vec(), Object::Dict(bs)); + if matches!(w.kind, WidgetKind::CheckBox { round: true, .. } | WidgetKind::Radio { round: true, .. }) { + d.set(ROUND_KEY.to_vec(), Object::Bool(true)); + } let mut ff: i64 = 0; if w.read_only { ff |= 1; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b177..e4464bbd45 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -350,6 +350,52 @@ fn form_text_the_standard_fonts_cannot_draw_is_reported() { assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts"))); } +/// A check box or radio button laid out from a round check button (`shape="round"`) carries +/// `ROUND_KEY`, so the forms layer draws its background and border round; a square one (the +/// default) doesn't. +#[test] +fn round_check_buttons_are_marked_for_a_round_appearance() { + let check = |name: &str, shape: &str, items: &str| { + format!(r#"{items}"#) + }; + let on_off = "10"; + let xdp = format!( + r##" +"##, + check("roundBox", r#" shape="round""#, on_off), + check("squareBox", "", on_off), + check("roundRadio", r#" shape="round""#, "a"), + check("squareRadio", r#" shape="square""#, "b"), + ); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + render_into(&mut doc).unwrap(); + // Every widget among the form's fields and their kids (a radio group's buttons), by the + // last step of its SOM path. + let catalog = doc.get(doc.root().unwrap()); + let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); + let mut todo: Vec<_> = + doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().iter().filter_map(Object::as_ref).collect(); + let mut round = std::collections::BTreeMap::new(); + while let Some(r) = todo.pop() { + let node = doc.get(r); + let d = node.as_dict().unwrap(); + if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)) { + todo.extend(kids.as_array().unwrap().iter().filter_map(Object::as_ref)); + continue; + } + let som = d.get(SOM_KEY).and_then(Object::as_string).map(|s| s.to_text()).unwrap_or_default(); + let name = som.rsplit('.').next().unwrap_or_default().split('[').next().unwrap_or_default().to_string(); + round.insert(name, matches!(d.get(crate::pdf::ROUND_KEY), Some(Object::Bool(true)))); + } + let expected: std::collections::BTreeMap = [("roundBox", true), ("squareBox", false), ("roundRadio", true), ("squareRadio", false)] + .into_iter() + .map(|(n, r)| (n.to_string(), r)) + .collect(); + assert_eq!(round, expected); +} + #[test] fn documents_without_xfa_are_not_touched() { let mut doc = Document::new_empty(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c5..4acde8aaca 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4452,8 +4452,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states", "crates/ui-egui/tests/it/forms.rs::tab_and_space_fill_in_a_check_box_between_text_fields", "crates/ui-egui/tests/it/forms.rs::clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box", "crates/ui-egui/tests/it/forms.rs::radio_buttons_take_tab_space_and_arrows"] -notes = "NoToggleToOff honoured; widgets without appearances get our own. Tab and Shift+Tab stop on check boxes and on each radio button, Space toggles the focused one, and the arrows move within a radio group (#2223); a click focuses the box too" +evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states", "crates/ui-egui/tests/it/forms.rs::tab_and_space_fill_in_a_check_box_between_text_fields", "crates/ui-egui/tests/it/forms.rs::clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box", "crates/ui-egui/tests/it/forms.rs::radio_buttons_take_tab_space_and_arrows", "crates/forms/src/tests.rs::a_round_xfa_check_button_draws_a_round_background_and_border", "crates/xfa/src/tests.rs::round_check_buttons_are_marked_for_a_round_appearance", "crates/engine/src/tests.rs::round_xfa_check_buttons_open_drawn_round"] +notes = "NoToggleToOff honoured; widgets without appearances get our own, drawn round for an XFA check button with shape=\"round\" (its background and border a circle inside the widget, as another viewer draws them on a synthetic form). Tab and Shift+Tab stop on check boxes and on each radio button, Space toggles the focused one, and the arrows move within a radio group (#2223); a click focuses the box too" [[feature]] id = "form.fill-choice" From 0008a1834514fd68db8f738d596b1c0729542c35 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:41:22 -0400 Subject: [PATCH 31/47] M12: XFA borders and rectangles with rounded corners are drawn rounded --- crates/xfa/src/layout.rs | 87 ++++++++++++++++++++++++++++++-- crates/xfa/src/model.rs | 24 ++++++++- crates/xfa/src/parse.rs | 20 +++++++- crates/xfa/src/pdf.rs | 74 ++++++++++++++++++++++++++- crates/xfa/src/tests.rs | 98 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 6 files changed, 295 insertions(+), 12 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 23342168e7..3d1781732b 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -39,13 +39,45 @@ const PAGE_COUNT_MARK: char = '\u{E000}'; #[derive(Clone, Debug, PartialEq)] pub enum Item { - Fill { rect: Rect, color: [f64; 3] }, - Line { from: (f64, f64), to: (f64, f64), width: f64, color: [f64; 3], dashed: bool }, + Fill { + rect: Rect, + color: [f64; 3], + }, + Line { + from: (f64, f64), + to: (f64, f64), + width: f64, + color: [f64; 3], + dashed: bool, + }, + /// A box with rounded (or inverted) corners: its fill, and its edge drawn inside it. + Rounded(Box), Text(Span), - Image { rect: Rect, data: Arc>, content_type: String }, + Image { + rect: Rect, + data: Arc>, + content_type: String, + }, Widget(Box), } +/// A box with rounded corners ([`Item::Rounded`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Rounded { + pub rect: Rect, + /// Top-left, top-right, bottom-right and bottom-left radii, each at most half the box's + /// shorter side. + pub radii: [f64; 4], + /// Corners whose curve goes into the box rather than around it, in the same order. + pub inverted: [bool; 4], + pub fill: Option<[f64; 3]>, + /// The edge: its width, colour and whether it is dashed. + pub edge: Option<(f64, [f64; 3], bool)>, + /// A page break cut the box here: that side has no edge, and its corners are square. + pub open_top: bool, + pub open_bottom: bool, +} + #[derive(Clone, Debug, PartialEq)] pub enum WidgetKind { Text, @@ -334,7 +366,7 @@ fn solid_extent(item: &Item) -> Option<(f64, f64)> { Item::Text(s) => Some((s.baseline - s.face.size * 0.8, s.baseline + s.face.size * 0.2)), Item::Image { rect, .. } => Some((rect.y, rect.bottom())), Item::Widget(w) => Some((w.rect.y, w.rect.bottom())), - Item::Fill { .. } | Item::Line { .. } => None, + Item::Fill { .. } | Item::Line { .. } | Item::Rounded(_) => None, } } @@ -369,6 +401,23 @@ fn divide(item: Item, cut: f64) -> (Option, Option) { (Some(Item::Line { from: lo, to: mid, width, color, dashed }), Some(Item::Line { from: mid, to: hi, width, color, dashed })) } } + Item::Rounded(r) => { + if r.rect.bottom() <= cut + 0.01 { + (Some(Item::Rounded(r)), None) + } else if r.rect.y >= cut - 0.01 { + (None, Some(Item::Rounded(r))) + } else { + // Each part keeps the corners on its side, rounded no more than its own height + // allows, and is open where it was cut. + let [tl, tr, br, bl] = r.radii; + let above = Rect::new(r.rect.x, r.rect.y, r.rect.w, cut - r.rect.y); + let below = Rect::new(r.rect.x, cut, r.rect.w, r.rect.bottom() - cut); + let fit = |v: f64, h: f64| v.min(h.max(0.0)); + let upper = Rounded { rect: above, radii: [fit(tl, above.h), fit(tr, above.h), 0.0, 0.0], open_bottom: true, ..(*r).clone() }; + let lower = Rounded { rect: below, radii: [0.0, 0.0, fit(br, below.h), fit(bl, below.h)], open_top: true, ..*r }; + (Some(Item::Rounded(Box::new(upper))), Some(Item::Rounded(Box::new(lower)))) + } + } other => (Some(other), None), } } @@ -380,6 +429,10 @@ fn moved(mut item: Item, dx: f64, dy: f64) -> Item { rect.x += dx; rect.y += dy; } + Item::Rounded(r) => { + r.rect.x += dx; + r.rect.y += dy; + } Item::Line { from, to, .. } => { *from = (from.0 + dx, from.1 + dy); *to = (to.0 + dx, to.1 + dy); @@ -1403,11 +1456,35 @@ impl Layouter<'_> { } } + /// Draw border `b` around `rect`: its fill, then each shown edge as a line inside the box, or + /// the whole box as one rounded shape when its corners are rounded. fn draw_border(&mut self, b: &Border, rect: Rect) -> Result<(), XfaError> { + let vis = b.visible_edges(); + // Rounded corners: the box is one shape, filled and edged along its curves, when its + // edges are all alike (all shown and the same, or all hidden). Edges that differ keep + // square corners. + let radii = b.radii(rect.w, rect.h); + let alike = b.edges.iter().all(|e| *e == b.edges[0]); + if radii.iter().any(|r| *r > 0.0) && (vis == [true; 4] && alike || vis == [false; 4]) { + let e = b.edges[0]; + let edge = vis[0].then(|| { + let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 }; + (e.thickness.clamp(0.1, 20.0), color, matches!(e.stroke, Stroke::Dashed | Stroke::Dotted)) + }); + let shape = Rounded { + rect, + radii, + inverted: b.corners.map(|c| c.inverted), + fill: b.fill().map(|f| f.0), + edge, + open_top: false, + open_bottom: false, + }; + return self.push(Item::Rounded(Box::new(shape))); + } if let Some(fill) = b.fill() { self.push(Item::Fill { rect, color: fill.0 })?; } - let vis = b.visible_edges(); let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); // Top, right, bottom, left; lines sit inside the box. let segs = [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x0, y1), (x1, y1)), ((x0, y0), (x0, y1))]; diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index b60180b7fa..f1b4da64fc 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -125,11 +125,22 @@ impl Edge { } } -/// A box border: four edges (top, right, bottom, left) and a fill. +/// One corner of a border: how far it rounds the box (`radius`), and whether the curve goes +/// into the box (`inverted`) rather than around it. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Corner { + pub presence: Presence, + pub radius: f64, + pub inverted: bool, +} + +/// A box border: four edges (top, right, bottom, left), four corners (top-left, top-right, +/// bottom-right, bottom-left) and a fill. #[derive(Clone, Debug, Default, PartialEq)] pub struct Border { pub presence: Presence, pub edges: [Edge; 4], + pub corners: [Corner; 4], pub fill: Option, } @@ -144,6 +155,15 @@ impl Border { pub fn fill(&self) -> Option { if self.presence == Presence::Visible { self.fill } else { None } } + + /// How far each corner (top-left, top-right, bottom-right, bottom-left) rounds a box `w` by + /// `h`: its radius when the corner is shown, no more than half the box's shorter side, and 0 + /// for a hidden corner or a hidden border. + pub fn radii(&self, w: f64, h: f64) -> [f64; 4] { + let most = (w.min(h) / 2.0).max(0.0); + let shown = self.presence == Presence::Visible; + self.corners.map(|c| if shown && c.presence == Presence::Visible && c.radius.is_finite() { c.radius.clamp(0.0, most) } else { 0.0 }) + } } /// Font properties as written on an element; `None` inherits from the ancestors. @@ -581,7 +601,7 @@ pub enum Value { slope_up: bool, edge: Edge, }, - Rectangle(Border), + Rectangle(Box), } impl Value { diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index fc9ad1a245..72f4ae18a3 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -146,7 +146,23 @@ fn parse_border(n: Option) -> Option { 3 => [e(0), e(1), e(2), e(1)], _ => [e(0), e(1), e(2), e(3)], }; - Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, fill: parse_fill(child(n, "fill")) }) + Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, corners: parse_corners(n), fill: parse_fill(child(n, "fill")) }) +} + +/// The `` children of a border or rectangle, top-left, top-right, bottom-right then +/// bottom-left: one applies to all four, and a corner not given is the last one given. Without +/// any, the corners are square. +fn parse_corners(n: XmlNode) -> [Corner; 4] { + let given: Vec = children(n, "corner") + .take(4) + .map(|c| Corner { + presence: Presence::parse(c.attribute("presence")), + radius: attr_measure(c, "radius").filter(|r| r.is_finite()).unwrap_or(0.0).max(0.0), + inverted: c.attribute("inverted") == Some("1"), + }) + .collect(); + let Some(last) = given.last().copied() else { return [Corner::default(); 4] }; + std::array::from_fn(|i| given.get(i).copied().unwrap_or(last)) } /// ``: typeface, size, weight, posture, underline, fill color and horizontal scale. @@ -378,7 +394,7 @@ fn parse_value(n: Option, warnings: &mut Vec) -> Value { "rectangle" => { let mut b = parse_border(Some(c)).unwrap_or_default(); b.fill = parse_fill(child(c, "fill")); - return Value::Rectangle(b); + return Value::Rectangle(Box::new(b)); } "date" | "time" | "dateTime" | "integer" | "decimal" | "float" | "boolean" => return Value::Text(text_of(c)), _ => {} diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 8e0c47d888..b5f5b3369e 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -8,7 +8,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use pdfcraft_fonts::{literal, win_ansi}; use crate::XfaError; -use crate::layout::{Action, BorderShape, Form, Item, Page, Widget, WidgetKind}; +use crate::layout::{Action, BorderShape, Form, Item, Page, Rounded, Widget, WidgetKind}; use crate::model::Rect; use crate::text::{Face, Family}; @@ -129,6 +129,77 @@ fn all_faces() -> Vec<(&'static str, &'static str)> { out } +/// The content that draws rounded box `r` on a page `page_h` tall: its fill along its curves, +/// then its edge inside it. A side a page break cut has no edge. +pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String { + let mut c = String::new(); + if let Some(color) = r.fill { + c.push_str(&format!("{} rg {}f\n", rgb(color), outline(r.rect, r.radii, r.inverted, (false, false), page_h))); + } + if let Some((w, color, dashed)) = r.edge { + // The edge's centre line, half its width inside the box, so the edge stays in: a corner + // curving around the box tightens by as much, and one curving into it widens. + let inset = Rect::new(r.rect.x + w / 2.0, r.rect.y + w / 2.0, (r.rect.w - w).max(0.0), (r.rect.h - w).max(0.0)); + let most = inset.w.min(inset.h) / 2.0; + let radii: [f64; 4] = std::array::from_fn(|i| match (r.radii[i], r.inverted[i]) { + (v, _) if v <= 0.0 => 0.0, + (v, true) => (v + w / 2.0).min(most), + (v, false) => (v - w / 2.0).clamp(0.0, most), + }); + let path = outline(inset, radii, r.inverted, (r.open_top, r.open_bottom), page_h); + c.push_str(&format!("q {} RG {} w {}{path}S Q\n", rgb(color), n(w), if dashed { "[2 2] 0 d " } else { "" })); + } + c +} + +/// The path around `rect` (top-left based, on a page `page_h` tall) with the corners `radii` +/// (top-left, top-right, bottom-right, bottom-left), each curving around the box or, when +/// `inverted`, into it. `open` leaves out the top or bottom side (and doesn't close the path): +/// those are where a page break cut the box. Ends with a space, for the paint operator. +fn outline(rect: Rect, radii: [f64; 4], inverted: [bool; 4], open: (bool, bool), page_h: f64) -> String { + let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); + let pt = |q: (f64, f64)| format!("{} {}", n(q.0), n(page_h - q.1)); + // Corner `i`: its point, then where its curve starts and ends, going clockwise. + let corner = |i: usize| { + let r = radii.get(i).copied().unwrap_or(0.0); + match i { + 0 => ((x0, y0), (x0, y0 + r), (x0 + r, y0)), + 1 => ((x1, y0), (x1 - r, y0), (x1, y0 + r)), + 2 => ((x1, y1), (x1, y1 - r), (x1 - r, y1)), + _ => ((x0, y1), (x0 + r, y1), (x0, y1 - r)), + } + }; + // Corner `i`'s curve, from its start: a quarter circle as one Bézier curve. + let curve = |i: usize| { + if radii.get(i).copied().unwrap_or(0.0) <= 0.0 { + return String::new(); + } + let (p, a, b) = corner(i); + const K: f64 = 0.5523; + let (c1, c2) = if inverted.get(i).copied().unwrap_or(false) { + // Centred on the corner itself. + ((a.0 + (b.0 - p.0) * K, a.1 + (b.1 - p.1) * K), (b.0 + (a.0 - p.0) * K, b.1 + (a.1 - p.1) * K)) + } else { + ((a.0 + (p.0 - a.0) * K, a.1 + (p.1 - a.1) * K), (b.0 + (p.0 - b.0) * K, b.1 + (p.1 - b.1) * K)) + }; + format!("{} {} {} c ", pt(c1), pt(c2), pt(b)) + }; + // Along corners `from`, joining them with straight sides. + let along = |from: &[usize]| { + let mut s = String::new(); + for &i in from { + s.push_str(&format!("{} l {}", pt(corner(i).1), curve(i))); + } + s + }; + match open { + (false, false) => format!("{} m {}{}{}{}h ", pt(corner(0).1), curve(0), along(&[1]), along(&[2]), along(&[3])), + (true, false) => format!("{} m {}{} l ", pt((x1, y0)), along(&[2, 3]), pt((x0, y0))), + (false, true) => format!("{} m {}{} l ", pt((x0, y1)), along(&[0, 1]), pt((x1, y1))), + (true, true) => format!("{} m {} l {} m {} l ", pt((x0, y0)), pt((x0, y1)), pt((x1, y0)), pt((x1, y1))), + } +} + /// A PDF appearance-state name for a check button's on value. fn state_name(on: &str) -> String { let s: String = on.trim().chars().map(|c| if c.is_whitespace() || "()<>[]{}/%#".contains(c) || c.is_control() { '_' } else { c }).collect(); @@ -588,6 +659,7 @@ impl Emitter<'_> { Item::Fill { rect, color } => { content.extend(format!("{} rg {} {} {} {} re f\n", rgb(*color), n(rect.x), n(h - rect.bottom()), n(rect.w), n(rect.h)).bytes()); } + Item::Rounded(r) => content.extend(rounded(r, h).bytes()), Item::Line { from, to, width, color, dashed } => { content.extend( format!( diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b177..6c5d7e809e 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2048,6 +2048,104 @@ fn a_multi_line_field_without_a_height_grows_to_hold_its_text() { assert!((notes.rect.h - 108.0).abs() < 0.01, "{:?}", notes.rect); } +/// The rounded boxes laid out on `page`, in order. +fn rounded(page: &Page) -> Vec<&crate::layout::Rounded> { + page.items.iter().filter_map(|i| if let Item::Rounded(r) = i { Some(r.as_ref()) } else { None }).collect() +} + +/// A border or rectangle with rounded corners is one shape, filled and edged along its curves: +/// one corner applies to all four, a corner not given repeats the last one given, a radius is at +/// most half the box's shorter side, and inverted corners are kept. Hidden corners, and edges +/// that differ from each other, keep the square box. +#[test] +fn rounded_corners_make_a_border_one_rounded_shape() { + let draw = |name: &str, y: f64, border: &str| { + format!(r#"{name}{border}"#) + }; + let body = [ + draw("round", 0.0, r#""#), + draw("mixed", 1.2, r#""#), + draw("huge", 2.4, r#""#), + draw("hiddenCorner", 3.6, r#""#), + draw("unlikeEdges", 4.8, r#""#), + draw("fillOnly", 6.0, r#""#), + r#""#.to_string(), + ] + .concat(); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"{body}"#))) + .unwrap(); + let shapes = rounded(&form.pages[0]); + let at = |y: f64| shapes.iter().copied().find(|r| (r.rect.y - 36.0 - y * 72.0).abs() < 0.01); + let round = at(0.0).expect("the rounded border is one shape"); + assert_eq!((round.rect.w, round.rect.h, round.radii), (144.0, 72.0, [18.0; 4])); + assert_eq!((round.fill, round.edge), (Some([230.0 / 255.0; 3]), Some((1.0, [1.0, 0.0, 0.0], false)))); + let mixed = at(1.2).expect("the box with two corners given"); + assert_eq!((mixed.radii, mixed.inverted), ([7.2, 14.4, 14.4, 14.4], [false, true, true, true])); + assert_eq!(at(2.4).expect("the huge radius").radii, [36.0; 4], "at most half the shorter side"); + assert!(at(3.6).is_none() && at(4.8).is_none(), "hidden corners and unlike edges keep a square box"); + let fill_only = at(6.0).expect("a fill with hidden edges is rounded too"); + assert_eq!((fill_only.fill.is_some(), fill_only.edge), (true, None)); + let shape = at(7.2).expect("a rectangle drawn as a value"); + assert!((shape.radii[0] - 5.08 * 72.0 / 25.4).abs() < 1e-9 && shape.edge.is_some()); + // The square boxes are drawn as before: edges as lines. + let lines_at = |y: f64| { + form.pages[0] + .items + .iter() + .filter(|i| matches!(i, Item::Line { from, .. } if from.1 >= 36.0 + y * 72.0 - 1.0 && from.1 <= 36.0 + (y + 1.0) * 72.0 + 1.0)) + .count() + }; + assert_eq!((lines_at(3.6), lines_at(4.8)), (4, 2)); +} + +/// A rounded box a page break cuts keeps its rounded corners on each page, square where it was +/// cut, and its edge stops there instead of closing across the cut. +#[test] +fn a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page() { + // A 12-inch box in a 10-inch content area: it is cut at the area's bottom. + let body = r#"Note"#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap(); + assert_eq!(form.pages.len(), 2); + let (first, last) = (rounded(&form.pages[0]), rounded(&form.pages[1])); + assert_eq!((first.len(), last.len()), (1, 1), "{first:?} {last:?}"); + assert_eq!((first[0].radii, first[0].open_top, first[0].open_bottom), ([18.0, 18.0, 0.0, 0.0], false, true)); + assert_eq!((last[0].radii, last[0].open_top, last[0].open_bottom), ([0.0, 0.0, 18.0, 18.0], true, false)); + assert!((first[0].rect.h + last[0].rect.h - 12.0 * 72.0).abs() < 0.01, "the two parts make the whole box"); +} + +/// A rounded box is written as one path: filled along its curves, then edged half its width +/// inside, the curves as quarter circles. An inverted corner curves into the box, and the edge +/// of a box a page break cut doesn't close across the cut. +#[test] +fn rounded_boxes_are_written_as_curved_paths() { + use crate::layout::Rounded; + // A 2-by-1-inch box at the page's top left, on a page 792pt tall. + let shape = |inverted: [bool; 4], edge: bool, open: (bool, bool)| Rounded { + rect: Rect::new(36.0, 36.0, 144.0, 72.0), + radii: [18.0; 4], + inverted, + fill: Some([0.9, 0.9, 0.9]), + edge: edge.then_some((2.0, [1.0, 0.0, 0.0], false)), + open_top: open.0, + open_bottom: open.1, + }; + let content = |r: Rounded| crate::pdf::rounded(&r, 792.0); + let ops = content(shape([false; 4], true, (false, false))); + // The fill: from the top-left corner's start, a quarter circle to (54, 36), closed. + assert!(ops.contains("0.9 0.9 0.9 rg 36 738 m 36 747.941 44.059 756 54 756 c "), "{ops}"); + assert!(ops.contains(" h f\n"), "{ops}"); + // The edge: 1pt inside, the corners 1pt tighter. + assert!(ops.contains("q 1 0 0 RG 2 w 37 738 m 37 747.389 44.611 755 54 755 c "), "{ops}"); + assert!(ops.contains(" h S Q\n"), "{ops}"); + // Inverted: the curve bends towards the box's inside. + let inv = content(shape([true; 4], false, (false, false))); + assert!(inv.contains("36 738 m 45.941 738 54 746.059 54 756 c "), "{inv}"); + // Cut at the bottom: the edge runs from the bottom left round the top and back down, open. + let cut = content(Rounded { radii: [18.0, 18.0, 0.0, 0.0], ..shape([false; 4], true, (false, true)) }); + let edge = cut.lines().find(|l| l.contains(" RG ")).unwrap_or_default(); + assert!(edge.starts_with("q 1 0 0 RG 2 w 37 685 m 37 738 l ") && edge.ends_with(" 179 685 l S Q") && !edge.contains(" h "), "{edge}"); +} + #[test] fn a_page_break_keeps_a_fields_border_with_the_field() { // 6in of heading, then a bordered 5in field: it doesn't fit the 10in area and moves to diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c5..e4eff22cbf 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5249,8 +5249,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn"] -notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths"] +notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rounded and inverted border corners, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From 21f3241e6d1604b06c7f283a925c94173554210e Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:45:40 -0400 Subject: [PATCH 32/47] M12: a round border as wide as its check button fills it as a disc --- crates/forms/src/appearance.rs | 10 ++++++++-- crates/forms/src/tests.rs | 14 +++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 7f55e192ea..e5fe07eb9b 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -358,7 +358,8 @@ fn is_round(doc: &Document, wd: &Dict) -> bool { /// [`frame`] for a round check button: the `/MK /BG` background as a disc and the `/MK /BC` /// border as a circle, both centred in the `w` × `h` box and as wide as its shorter side. A -/// dashed border stays dashed; any other style is drawn solid. Returns the border width. +/// dashed border stays dashed; any other style is drawn solid, and a border as wide as the +/// circle fills it. Returns the border width. fn round_frame(doc: &Document, wd: &Dict, mk: Option<&Dict>, w: f64, h: f64) -> (String, f64) { let mut c = String::new(); let (cx, cy, r) = (w / 2.0, h / 2.0, w.min(h).max(0.0) / 2.0); @@ -368,10 +369,15 @@ fn round_frame(doc: &Document, wd: &Dict, mk: Option<&Dict>, w: f64, h: f64) -> let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0); match color_array(doc, mk, b"BC") { + // A border as wide as the circle fills it: a disc in the border's colour. + Some(bc) if bw > 0.0 && bw >= 2.0 * r => { + c.push_str(&format!("{bc}\n{}f\n", circle_path(cx, cy, r))); + (c, 2.0 * r) + } Some(bc) if bw > 0.0 => { let dashed = bs.as_ref().and_then(|b| b.name(b"S")) == Some(b"D"); // The stroke is centred on the path: inset by half its width, it stays inside. - let path = circle_path(cx, cy, (r - bw / 2.0).max(0.0)); + let path = circle_path(cx, cy, r - bw / 2.0); c.push_str(&format!("{}\n{} w\n{}{path}S\n[] 0 d\n", stroke_op(&bc), n(bw), if dashed { "[3] 0 d\n" } else { "" })); (c, bw) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index fb48bd39e7..4255cbac88 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -388,8 +388,8 @@ fn opt_export_values_select_button_states() { } /// A check box laid out for a round XFA check button (`/PCXfaRound true`) draws its background -/// and border as a circle inside the widget, inset by half the border so the stroke stays in; -/// one without the key keeps its rectangle. +/// and border as a circle inside the widget, inset by half the border so the stroke stays in, +/// or a disc when the border is as wide as the circle; one without the key keeps its rectangle. #[test] fn a_round_xfa_check_button_draws_a_round_background_and_border() { let widget = |name: &str, extra: &str| { @@ -400,14 +400,15 @@ fn a_round_xfa_check_button_draws_a_round_background_and_border() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), - "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R] >>".into(), - "<< /Fields [5 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), widget("round", " /PCXfaRound true"), widget("square", ""), widget("dashed", " /PCXfaRound true /BS << /W 2 /S /D >>").replace("/BS << /W 1 /S /S >>", ""), + widget("thick", " /PCXfaRound true /BS << /W 30 /S /S >>").replace("/BS << /W 1 /S /S >>", ""), ]; let mut doc = document(&objs); - for name in ["round", "square", "dashed"] { + for name in ["round", "square", "dashed", "thick"] { redraw_field(&mut doc, name).unwrap(); } // The Off state: only the frame. @@ -428,6 +429,9 @@ fn a_round_xfa_check_button_draws_a_round_background_and_border() { assert!(square.contains("0 0 20 20 re f") && square.contains("re S"), "a square box keeps its rectangle: {square}"); let dashed = off("dashed"); assert!(dashed.contains("[3] 0 d\n19 10 m"), "a dashed round border stays dashed: {dashed}"); + // A border wider than the 20pt circle fills it: a disc in the border's colour, no stroke. + let thick = off("thick"); + assert!(thick.contains("1 0 0 rg\n20 10 m") && !thick.contains(" S\n"), "a border as wide as the circle is a disc: {thick}"); } /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an From d91028a99012a6ac8f0ba4d4bd788a67931b1df7 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:47:19 -0400 Subject: [PATCH 33/47] M12: a link's address is shared by its runs, words and spans, not copied --- crates/xfa/src/model.rs | 5 +++-- crates/xfa/src/parse.rs | 4 ++-- crates/xfa/src/tests.rs | 14 ++++++++++++++ crates/xfa/src/text.rs | 10 ++++++---- parity/acrobat-features.toml | 2 +- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 843d78f34b..964388f5a2 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -633,8 +633,9 @@ pub struct Run { pub h_scale: Option, /// A floating field embedded in the text (`xfa:embed="#id"`), shown by its value. pub embed: Option, - /// Where the text links to (``), as the template writes it. - pub link: Option, + /// Where the text links to (``), as the template writes it: shared by every run + /// of the link's text, not copied. + pub link: Option>, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 13029bf05d..362bde9942 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -211,7 +211,7 @@ fn parse_rich(body: XmlNode) -> RichText { size: Option, h_scale: Option, /// The `href` of the `` the text is in. - link: Option, + link: Option>, } /// `base` with the CSS declarations in `css` applied: weight, style, underline, size and /// `xfa-font-horizontal-scale`. Anything else is ignored. @@ -292,7 +292,7 @@ fn parse_rich(body: XmlNode) -> RichText { // anchor) leaves the text as it was. "a" => { if let Some(href) = c.attribute("href").map(str::trim).filter(|h| !h.is_empty()) { - st.link = Some(href.to_string()); + st.link = Some(std::sync::Arc::from(href)); } } _ => {} diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 665bf82e50..7cf07afec0 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -412,6 +412,20 @@ fn rich_text_links_keep_their_address() { ); } +/// A link's address is held once, however many runs, words and lines its text takes: every +/// piece shares it rather than copying it, so a long address over many words stays one string. +#[test] +fn a_links_pieces_share_one_address() { + let words = "one two three four five six seven eight nine ten ".repeat(8); + let href = format!("https://example.com/{}", "x".repeat(1500)); + let xdp = rich_draw_form("2in", &format!(r#"{words}bold {words}"#)); + let form = layout_xml(&xdp).unwrap(); + let links: Vec> = + form.pages.iter().flat_map(|p| &p.items).filter_map(|i| if let Item::Text(s) = i { s.link.clone() } else { None }).collect(); + assert!(links.len() > 10, "the link's text takes many lines: {}", links.len()); + assert!(links.iter().all(|l| std::sync::Arc::ptr_eq(l, &links[0]) && **l == *href), "every piece shares one address"); +} + /// A link's text becomes a link annotation on the page, one over each line it takes when it /// wraps, each over its own text; a second layout replaces them rather than adding more. #[test] diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index 99abda281e..d3eda99a87 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -153,8 +153,9 @@ pub struct Span { pub baseline: f64, pub text: String, pub face: Face, - /// Where the text links to (its run's ``), as the template writes it. - pub link: Option, + /// Where the text links to (its run's ``), as the template writes it, shared with the + /// link's other spans. + pub link: Option>, } /// A laid-out block of text. @@ -170,8 +171,9 @@ struct Word { text: String, face: Face, width: f64, - /// The link of the run the word came from. - link: Option, + /// The link of the run the word came from, shared, not copied, so a long link over many + /// words costs one address. + link: Option>, } /// The width of `w` without the spaces it ends with: what it takes at the end of a line, where diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 20b0ff59de..0c5ec4eed3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5249,7 +5249,7 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools"] +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools"] notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] From ac181479ef8bbd837462e0caaa83eea876ff101d Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:03:53 -0400 Subject: [PATCH 34/47] M12: a round check button's underline border stays an underline --- crates/forms/src/appearance.rs | 12 +++++++++--- crates/forms/src/tests.rs | 13 +++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index e5fe07eb9b..23d00e21f2 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -358,8 +358,8 @@ fn is_round(doc: &Document, wd: &Dict) -> bool { /// [`frame`] for a round check button: the `/MK /BG` background as a disc and the `/MK /BC` /// border as a circle, both centred in the `w` × `h` box and as wide as its shorter side. A -/// dashed border stays dashed; any other style is drawn solid, and a border as wide as the -/// circle fills it. Returns the border width. +/// dashed border stays dashed and an underline stays an underline; any other style is drawn +/// solid, and a border as wide as the circle fills it. Returns the border width. fn round_frame(doc: &Document, wd: &Dict, mk: Option<&Dict>, w: f64, h: f64) -> (String, f64) { let mut c = String::new(); let (cx, cy, r) = (w / 2.0, h / 2.0, w.min(h).max(0.0) / 2.0); @@ -368,14 +368,20 @@ fn round_frame(doc: &Document, wd: &Dict, mk: Option<&Dict>, w: f64, h: f64) -> } let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0); + let style = bs.as_ref().and_then(|b| b.name(b"S").map(<[u8]>::to_vec)).unwrap_or_default(); match color_array(doc, mk, b"BC") { + // An underline stays one: only the bottom edge, as for any widget. + Some(bc) if bw > 0.0 && style == b"U" => { + c.push_str(&format!("{}\n{} w\n0 {} m {} {} l S\n", stroke_op(&bc), n(bw), n(bw / 2.0), n(w), n(bw / 2.0))); + (c, bw) + } // A border as wide as the circle fills it: a disc in the border's colour. Some(bc) if bw > 0.0 && bw >= 2.0 * r => { c.push_str(&format!("{bc}\n{}f\n", circle_path(cx, cy, r))); (c, 2.0 * r) } Some(bc) if bw > 0.0 => { - let dashed = bs.as_ref().and_then(|b| b.name(b"S")) == Some(b"D"); + let dashed = style == b"D"; // The stroke is centred on the path: inset by half its width, it stays inside. let path = circle_path(cx, cy, r - bw / 2.0); c.push_str(&format!("{}\n{} w\n{}{path}S\n[] 0 d\n", stroke_op(&bc), n(bw), if dashed { "[3] 0 d\n" } else { "" })); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 4255cbac88..8aa34f0e1d 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -389,7 +389,8 @@ fn opt_export_values_select_button_states() { /// A check box laid out for a round XFA check button (`/PCXfaRound true`) draws its background /// and border as a circle inside the widget, inset by half the border so the stroke stays in, -/// or a disc when the border is as wide as the circle; one without the key keeps its rectangle. +/// or a disc when the border is as wide as the circle (an underline stays one); one without the +/// key keeps its rectangle. #[test] fn a_round_xfa_check_button_draws_a_round_background_and_border() { let widget = |name: &str, extra: &str| { @@ -400,15 +401,16 @@ fn a_round_xfa_check_button_draws_a_round_background_and_border() { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), - "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), - "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>".into(), widget("round", " /PCXfaRound true"), widget("square", ""), widget("dashed", " /PCXfaRound true /BS << /W 2 /S /D >>").replace("/BS << /W 1 /S /S >>", ""), widget("thick", " /PCXfaRound true /BS << /W 30 /S /S >>").replace("/BS << /W 1 /S /S >>", ""), + widget("underlined", " /PCXfaRound true /BS << /W 1 /S /U >>").replace("/BS << /W 1 /S /S >>", ""), ]; let mut doc = document(&objs); - for name in ["round", "square", "dashed", "thick"] { + for name in ["round", "square", "dashed", "thick", "underlined"] { redraw_field(&mut doc, name).unwrap(); } // The Off state: only the frame. @@ -432,6 +434,9 @@ fn a_round_xfa_check_button_draws_a_round_background_and_border() { // A border wider than the 20pt circle fills it: a disc in the border's colour, no stroke. let thick = off("thick"); assert!(thick.contains("1 0 0 rg\n20 10 m") && !thick.contains(" S\n"), "a border as wide as the circle is a disc: {thick}"); + // An underline stays the bottom edge, under a round background. + let underlined = off("underlined"); + assert!(underlined.contains("1 0 0 RG\n1 w\n0 0.5 m 20 0.5 l S"), "an underline stays an underline: {underlined}"); } /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an From 3cf6e5f716a9195b985f27e56be4d340c91732a6 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:05:18 -0400 Subject: [PATCH 35/47] M12: a link's pieces don't join across a rule, picture or field between them --- crates/xfa/src/pdf.rs | 10 ++++++++++ crates/xfa/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 4ca32c80ac..a86aaec938 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -586,6 +586,16 @@ impl Emitter<'_> { // The link the text before ended in, which a next piece of it on the line widens. let mut last_link: Option = None; for (i, item) in page.items.iter().enumerate() { + // A link's pieces join only across text and the underline drawn just before it: a fill, + // picture, field or a line that isn't level between them ends the link there. + let keeps_link = match item { + Item::Text(_) => true, + Item::Line { from, to, .. } => (from.1 - to.1).abs() < 0.01, + _ => false, + }; + if !keeps_link { + last_link = None; + } match item { Item::Fill { rect, color } => { content.extend(format!("{} rg {} {} {} {} re f\n", rgb(*color), n(rect.x), n(h - rect.bottom()), n(rect.w), n(rect.h)).bytes()); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 7cf07afec0..414d35421c 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -469,6 +469,39 @@ fn links_in_rich_text_become_link_annotations_one_per_line() { assert!((r[2] - r[0] - width).abs() < 0.01, "over the whole address: {} against {width}", r[2] - r[0]); } +/// Two pieces of one link side by side on a line join into one link, but a rule drawn between +/// them keeps them apart, so the link never covers what lies between its pieces; an underline +/// (a level line drawn with its text) doesn't. +#[test] +fn a_rule_between_two_pieces_of_a_link_keeps_them_apart() { + let piece = |name: &str, x: &str, underline: bool| { + let u = if underline { ";text-decoration:underline" } else { "" }; + format!( + r#"

Go

"# + ) + }; + let rule = r#""#; + let form = |between: &str, underline: bool| { + format!( + r##" +"##, + piece("first", "0in", underline), + piece("second", "0.25in", underline) + ) + }; + let links = |xdp: &str| { + let mut doc = Document::open(Arc::new(shell(xdp))).unwrap(); + render_into(&mut doc).unwrap(); + text_links(&doc).len() + }; + assert_eq!(links(&form("", false)), 1, "side by side, the pieces join"); + assert_eq!(links(&form("", true)), 1, "an underline doesn't part them"); + assert_eq!(links(&form(rule, false)), 2, "a rule between them does"); +} + /// Only web and email addresses become links. Scripts, files, data, relative paths and empty /// or overlong addresses stay plain text (still drawn), with one warning for the form. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0c5ec4eed3..ceb7fe6d86 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5249,7 +5249,7 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools"] +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools"] notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] From 3355edad4200e2abbb9abf75ee5b00fbdbb2ea66 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:19:25 -0400 Subject: [PATCH 36/47] M12: only a link's own underline joins its pieces, and web links need a host --- crates/xfa/src/layout.rs | 7 ++++++- crates/xfa/src/pdf.rs | 19 ++++++++++++++++--- crates/xfa/src/tests.rs | 11 +++++++---- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 23342168e7..02a8e6e6ee 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -37,6 +37,11 @@ const MAX_IMAGE_BYTES: usize = 256 << 20; /// Placeholder for the page count in text laid out before the count is known. const PAGE_COUNT_MARK: char = '\u{E000}'; +/// How far below its baseline an underline is drawn, in the text's size: an underline is the +/// level line pushed just before its span, this far below it (the PDF writer tells it apart from +/// a rule by this). +pub(crate) const UNDERLINE_DROP: f64 = 0.1; + #[derive(Clone, Debug, PartialEq)] pub enum Item { Fill { rect: Rect, color: [f64; 3] }, @@ -521,7 +526,7 @@ impl Layouter<'_> { for s in b.spans { if s.face.underline { let w = s.face.width(&s.text); - let y = s.baseline + s.face.size * 0.1; + let y = s.baseline + s.face.size * UNDERLINE_DROP; self.push(Item::Line { from: (s.x, y), to: (s.x + w, y), width: (s.face.size * 0.06).max(0.3), color: s.face.color, dashed: false })?; } if !s.text.trim().is_empty() { diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index a86aaec938..833fae875d 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -587,10 +587,13 @@ impl Emitter<'_> { let mut last_link: Option = None; for (i, item) in page.items.iter().enumerate() { // A link's pieces join only across text and the underline drawn just before it: a fill, - // picture, field or a line that isn't level between them ends the link there. + // picture, field or any other line (a rule) between them ends the link there. let keeps_link = match item { Item::Text(_) => true, - Item::Line { from, to, .. } => (from.1 - to.1).abs() < 0.01, + Item::Line { from, to, .. } => matches!(page.items.get(i + 1), Some(Item::Text(s)) + if (from.1 - to.1).abs() < 0.01 + && (from.0 - s.x).abs() < 0.01 + && (from.1 - (s.baseline + s.face.size * crate::layout::UNDERLINE_DROP)).abs() < 0.01), _ => false, }; if !keeps_link { @@ -772,10 +775,20 @@ fn link_target(href: &str) -> Option<&str> { } let (scheme, rest) = href.split_once(':')?; let web = scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"); - let ok = if web { rest.starts_with("//") && rest.len() > 2 } else { scheme.eq_ignore_ascii_case("mailto") && !rest.is_empty() }; + let ok = if web { rest.strip_prefix("//").is_some_and(has_host) } else { scheme.eq_ignore_ascii_case("mailto") && !rest.is_empty() }; ok.then_some(href) } +/// Whether `after` (a web address past its `//`) names a host: what comes before the path, query +/// or fragment, past any user name and before any port, isn't empty. +fn has_host(after: &str) -> bool { + let authority = after.split(['/', '?', '#']).next().unwrap_or_default(); + let host = authority.rsplit('@').next().unwrap_or_default(); + // An IPv6 host is bracketed and holds colons of its own. + let host = if host.starts_with('[') { host } else { host.split(':').next().unwrap_or_default() }; + !host.is_empty() +} + /// Replace the document's pages with the laid-out form and add its fields to the AcroForm. The /// XFA packets and everything else in the catalog stay. /// The image XObjects earlier passes wrote for pictures, by the SHA-256 each records: those on diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 414d35421c..e4a0c410d3 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -481,6 +481,7 @@ fn a_rule_between_two_pieces_of_a_link_keeps_them_apart() { ) }; let rule = r#""#; + let level_rule = r#""#; let form = |between: &str, underline: bool| { format!( r##" @@ -500,19 +501,21 @@ fn a_rule_between_two_pieces_of_a_link_keeps_them_apart() { assert_eq!(links(&form("", false)), 1, "side by side, the pieces join"); assert_eq!(links(&form("", true)), 1, "an underline doesn't part them"); assert_eq!(links(&form(rule, false)), 2, "a rule between them does"); + assert_eq!(links(&form(level_rule, false)), 2, "and so does a level one, which isn't an underline"); } -/// Only web and email addresses become links. Scripts, files, data, relative paths and empty -/// or overlong addresses stay plain text (still drawn), with one warning for the form. +/// Only web and email addresses become links. Scripts, files, data, relative paths, web +/// addresses without a host, and empty or overlong addresses stay plain text (still drawn), with +/// one warning for the form. #[test] fn links_to_anything_but_web_and_email_addresses_stay_plain_text() { let long = format!("https://example.com/{}", "a".repeat(2100)); let body = format!( - r#"one two three four five six seven"# + r#"one two three four five six seven eight nine ten"# ); let mut doc = Document::open(Arc::new(shell(&rich_draw_form("7in", &body)))).unwrap(); let report = render_into(&mut doc).unwrap(); - assert_eq!(text_links(&doc).into_iter().map(|(u, _)| u).collect::>(), ["MailTo:a@example.com"]); + assert_eq!(text_links(&doc).into_iter().map(|(u, _)| u).collect::>(), ["MailTo:a@example.com", "https://user@example.com:8443/x"]); let warned: Vec<&String> = report.warnings.iter().filter(|w| w.contains("link")).collect(); assert_eq!(warned.len(), 1, "one warning for the form: {:?}", report.warnings); assert!(warned[0].contains("http, https") && warned[0].contains("mailto"), "{warned:?}"); From 352ebb1477d6dd9ae875f7b6ab516d189f07ceee Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:28:22 -0400 Subject: [PATCH 37/47] M12: a backslash ends a web link's host, as web addresses read it --- crates/xfa/src/pdf.rs | 7 ++++--- crates/xfa/src/tests.rs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 833fae875d..61b287e3d4 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -779,10 +779,11 @@ fn link_target(href: &str) -> Option<&str> { ok.then_some(href) } -/// Whether `after` (a web address past its `//`) names a host: what comes before the path, query -/// or fragment, past any user name and before any port, isn't empty. +/// Whether `after` (a web address past its `//`) names a host: what comes before the path (which a +/// backslash starts too, as web addresses read it), query or fragment, past any user name and +/// before any port, isn't empty. fn has_host(after: &str) -> bool { - let authority = after.split(['/', '?', '#']).next().unwrap_or_default(); + let authority = after.split(['/', '\\', '?', '#']).next().unwrap_or_default(); let host = authority.rsplit('@').next().unwrap_or_default(); // An IPv6 host is bracketed and holds colons of its own. let host = if host.starts_with('[') { host } else { host.split(':').next().unwrap_or_default() }; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index e4a0c410d3..9901903bcc 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -511,7 +511,7 @@ fn a_rule_between_two_pieces_of_a_link_keeps_them_apart() { fn links_to_anything_but_web_and_email_addresses_stay_plain_text() { let long = format!("https://example.com/{}", "a".repeat(2100)); let body = format!( - r#"one two three four five six seven eight nine ten"# + r#"one two three four five six seven eight nine nine and a half ten"# ); let mut doc = Document::open(Arc::new(shell(&rich_draw_form("7in", &body)))).unwrap(); let report = render_into(&mut doc).unwrap(); From f5e74991ed0fce494451dda51ea64fae390b119d Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:30:43 -0400 Subject: [PATCH 38/47] M12: flowed XFA subforms are drawn with their border and fill --- crates/xfa/src/layout.rs | 52 ++++++++++++++++++++++++++++++++++++ crates/xfa/src/tests.rs | 36 +++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 23342168e7..241814abfd 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -177,6 +177,17 @@ struct Measured { size: (f64, f64), } +/// A point in the flow ([`Layouter::flow_mark`]): the page and content area, the height +/// reached there, and how many items the page had then. +#[derive(Clone, Copy, Debug)] +struct FlowMark { + page: usize, + area: usize, + content: usize, + y: f64, + items: usize, +} + struct Cursor { area: usize, content: usize, @@ -596,6 +607,42 @@ impl Layouter<'_> { } } + /// Where the flow is now ([`FlowMark`]); `None` before the first page. + fn flow_mark(&self) -> Option { + let c = self.cur.as_ref()?; + Some(FlowMark { page: self.pages.len(), area: c.area, content: c.content, y: c.y, items: self.pages.last().map_or(0, |p| p.items.len()) }) + } + + /// Draw a flowed subform's border `b` (edges and fill) round what it took since `start`, `x` + /// and `w` wide, under the content drawn since: once its content is placed, its height is + /// known. A subform that didn't stay in one content area is drawn without its border, and the + /// layout says so. + fn draw_flowed_border(&mut self, b: &Border, start: Option, x: f64, w: f64) -> Result<(), XfaError> { + let Some(s) = start else { return Ok(()) }; + if b.fill().is_none() && b.visible_edges() == [false; 4] { + return Ok(()); + } + let (_, end, _) = self.cursor(); + let same_area = self.cur.as_ref().is_some_and(|c| c.area == s.area && c.content == s.content); + if self.pages.len() != s.page || !same_area { + self.warn_once("a bordered section that doesn't fit in one content area is drawn without its border"); + return Ok(()); + } + if end - s.y < 0.01 { + return Ok(()); + } + let before = self.pages.last().map_or(0, |p| p.items.len()); + self.draw_border(b, Rect::new(x, s.y, w, end - s.y))?; + // Moved back to where the subform started, so its content is drawn over them. + if let Some(p) = self.pages.last_mut() { + let added = p.items.len().saturating_sub(before); + if let Some(tail) = p.items.get_mut(s.items..) { + tail.rotate_right(added.min(tail.len())); + } + } + Ok(()) + } + /// Move the cursor down (margins and spacing: the area still counts as empty). fn advance(&mut self, dy: f64) { if let Some(c) = &mut self.cur { @@ -794,6 +841,8 @@ impl Layouter<'_> { continue; } let m = sf.common.margin; + // Where the subform starts: its border and fill go round what it takes from here. + let start = self.flow_mark(); self.advance(m.top); let inner_x = x + m.left; let inner_w = (w - m.horizontal()).max(1.0); @@ -818,6 +867,9 @@ impl Layouter<'_> { } } self.advance(m.bottom); + if let Some(b) = &sf.common.border { + self.draw_flowed_border(b, start, x, w)?; + } } if sf.break_after_page { self.new_page()?; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b177..5cdd9ec688 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2048,6 +2048,42 @@ fn a_multi_line_field_without_a_height_grows_to_hold_its_text() { assert!((notes.rect.h - 108.0).abs() < 0.01, "{:?}", notes.rect); } +/// A flowed subform without a fixed height gets its border and fill round all it takes, margins +/// included, drawn under its content; a flowed table too. One that doesn't fit in one content +/// area keeps no border, and the layout says so. +#[test] +fn a_flowed_subform_is_drawn_with_its_border_under_its_content() { + let field = |name: &str| format!(r#""#); + let boxed = |inner: &str| { + format!( + r#"{inner}"# + ) + }; + let lay = |body: &str| { + layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"{body}"#))).unwrap() + }; + // Two half-inch fields between a tenth and a fifth of an inch of margin: 1.3in from the top. + let form = lay(&boxed(&(field("a") + &field("b")))); + let items = &form.pages[0].items; + let fill = items.iter().position(|i| matches!(i, Item::Fill { .. })).expect("the subform's fill"); + let Some(Item::Fill { rect, .. }) = items.get(fill) else { panic!("item {fill} is the fill") }; + assert!((rect.y - 36.0).abs() < 0.01 && (rect.h - 1.3 * 72.0).abs() < 0.01 && (rect.w - 540.0).abs() < 0.01, "{rect:?}"); + assert_eq!(items.iter().filter(|i| matches!(i, Item::Line { .. })).count(), 4, "its four edges"); + let first_widget = items.iter().position(|i| matches!(i, Item::Widget(_))).unwrap(); + assert!(fill < first_widget, "the fill is under the fields"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // A flowed table's border too. + let table = r#"OneTwo"#; + let form = lay(table); + assert_eq!(form.pages[0].items.iter().filter(|i| matches!(i, Item::Line { .. })).count(), 4, "{:?}", form.pages[0].items); + // Thirty fields don't fit one page: no border, and one warning. + let many: String = (0..30).map(|i| field(&format!("f{i}"))).collect(); + let form = lay(&boxed(&many)); + assert!(form.pages.len() >= 2); + assert!(form.pages.iter().all(|p| !p.items.iter().any(|i| matches!(i, Item::Fill { .. }))), "no fill drawn"); + assert_eq!(form.warnings.iter().filter(|w| w.contains("bordered section")).count(), 1, "{:?}", form.warnings); +} + #[test] fn a_page_break_keeps_a_fields_border_with_the_field() { // 6in of heading, then a bordered 5in field: it doesn't fit the 10in area and moves to diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c5..53293bf3ec 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5249,8 +5249,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn"] -notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content"] +notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms that fit one content area (one that runs on past it is drawn without its border, with a layout warning), captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From d7da5dfe29c6595b3e03893589d9fe499cf04981 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:54:55 -0400 Subject: [PATCH 39/47] M12: a flowed XFA subform a page break cuts gets a closed border on each page, its margins repeated --- crates/xfa/src/layout.rs | 134 +++++++++++++++++++++++++++++------ crates/xfa/src/tests.rs | 36 ++++++++-- parity/acrobat-features.toml | 2 +- 3 files changed, 146 insertions(+), 26 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 241814abfd..39841c4692 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -188,6 +188,21 @@ struct FlowMark { items: usize, } +/// A flowed subform being laid out ([`Layouter::frames`]): where it sits across, its margins and +/// border, its part on the content area the flow is in, and its parts on earlier ones (page +/// index, the page's first item of the part, the part's box). +struct Frame { + x: f64, + w: f64, + margin: Margin, + border: Option, + part: FlowMark, + parts: Vec<(usize, usize, Rect)>, + /// The flow left a part's content area other than at a break between children (an item cut + /// across pages, a table's own break): the border is left out. + lost: bool, +} + struct Cursor { area: usize, content: usize, @@ -222,6 +237,9 @@ struct Layouter<'t> { /// The fields and draws of the block being placed: (first item, one past the last, top, /// bottom) on the last page. A page break keeps each whole (see `split_block`). leaves: Vec<(usize, usize, f64, f64)>, + /// The flowed subforms being laid out, outermost first: a break between their children + /// closes their parts on one content area and opens new ones on the next. + frames: Vec, } /// How many instances of a repeating subform to lay out: what the data holds once it holds @@ -267,6 +285,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result measured: RefCell::new(HashMap::new()), measures: Cell::new(0), leaves: Vec::new(), + frames: Vec::new(), }; let ctx = Ctx { font: Font::default(), para: Para::default(), som: String::new() }; l.new_page()?; @@ -613,32 +632,98 @@ impl Layouter<'_> { Some(FlowMark { page: self.pages.len(), area: c.area, content: c.content, y: c.y, items: self.pages.last().map_or(0, |p| p.items.len()) }) } - /// Draw a flowed subform's border `b` (edges and fill) round what it took since `start`, `x` - /// and `w` wide, under the content drawn since: once its content is placed, its height is - /// known. A subform that didn't stay in one content area is drawn without its border, and the - /// layout says so. - fn draw_flowed_border(&mut self, b: &Border, start: Option, x: f64, w: f64) -> Result<(), XfaError> { - let Some(s) = start else { return Ok(()) }; + /// Whether the flow is still in the content area `m` was taken in. + fn on_area(&self, m: &FlowMark) -> bool { + self.pages.len() == m.page && self.cur.as_ref().is_some_and(|c| c.area == m.area && c.content == m.content) + } + + /// Whether page `page` (an index) got anything from item `at` on. + fn has_items_from(&self, page: usize, at: usize) -> bool { + self.pages.get(page).is_some_and(|p| p.items.len() > at) + } + + /// Before placing a child `h` tall inside flowed subforms: when it doesn't fit what is left + /// of the content area but would fit a fresh one, break there ([`Self::break_frames`]). + fn break_before(&mut self, h: f64) -> Result<(), XfaError> { + let (rect, y, placed) = self.cursor(); + if self.frames.is_empty() || !placed || y + h <= rect.bottom() + 0.01 || h > rect.h + 0.01 { + return Ok(()); + } + self.break_frames() + } + + /// Break the flowed subforms being laid out ([`Self::frames`]) across content areas, as + /// another viewer breaks such a section on a synthetic form: each one's part here ends after + /// its content and its bottom margin (the innermost first, each inside the next), the flow + /// moves to the next content area, and each starts a new part there after its top margin + /// (the outermost first). + fn break_frames(&mut self) -> Result<(), XfaError> { + let (rect, mut y, _) = self.cursor(); + let page = self.pages.len().saturating_sub(1); + let here: Vec = self.frames.iter().map(|f| self.on_area(&f.part)).collect(); + let filled: Vec = self.frames.iter().map(|f| self.has_items_from(page, f.part.items)).collect(); + for ((f, here), filled) in self.frames.iter_mut().zip(here).zip(filled).rev() { + y = (y + f.margin.bottom).min(rect.bottom()); + if !here { + f.lost = true; + } else if filled && y - f.part.y >= 0.01 { + f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, y - f.part.y))); + } + } + self.next_area()?; + for i in 0..self.frames.len() { + let Some(mark) = self.flow_mark() else { break }; + let Some(f) = self.frames.get_mut(i) else { break }; + f.part = mark; + let top = f.margin.top; + self.advance(top); + } + Ok(()) + } + + /// Close flowed subform `f` once its content is placed: its last part ends where the flow + /// is, and its border (edges and fill) is drawn round each part, closed on every side, under + /// that part's content. One whose flow left a content area other than at a break between its + /// children (an item of its own cut across pages) is drawn without its border, and the layout + /// says so. + fn finish_frame(&mut self, mut f: Frame) -> Result<(), XfaError> { + let Some(b) = f.border.take() else { return Ok(()) }; if b.fill().is_none() && b.visible_edges() == [false; 4] { return Ok(()); } let (_, end, _) = self.cursor(); - let same_area = self.cur.as_ref().is_some_and(|c| c.area == s.area && c.content == s.content); - if self.pages.len() != s.page || !same_area { - self.warn_once("a bordered section that doesn't fit in one content area is drawn without its border"); + let page = self.pages.len().saturating_sub(1); + if !self.on_area(&f.part) { + f.lost = true; + } else if self.has_items_from(page, f.part.items) && end - f.part.y >= 0.01 { + f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, end - f.part.y))); + } + if f.lost { + self.warn_once("a bordered section whose own item a page break cuts is drawn without its border"); return Ok(()); } - if end - s.y < 0.01 { - return Ok(()); + // The last part first: a part's place on its page stays where it was found. + for (page, at, rect) in f.parts.into_iter().rev() { + self.insert_border(&b, rect, page, at)?; } + Ok(()) + } + + /// Draw border `b` round `rect` on page `page` (an index), under what that page got from item + /// `at` on. + fn insert_border(&mut self, b: &Border, rect: Rect, page: usize, at: usize) -> Result<(), XfaError> { let before = self.pages.last().map_or(0, |p| p.items.len()); - self.draw_border(b, Rect::new(x, s.y, w, end - s.y))?; - // Moved back to where the subform started, so its content is drawn over them. - if let Some(p) = self.pages.last_mut() { - let added = p.items.len().saturating_sub(before); - if let Some(tail) = p.items.get_mut(s.items..) { - tail.rotate_right(added.min(tail.len())); + self.draw_border(b, rect)?; + let drawn: Vec = match self.pages.last_mut() { + Some(p) => { + let from = before.min(p.items.len()); + p.items.drain(from..).collect() } + None => return Ok(()), + }; + if let Some(target) = self.pages.get_mut(page) { + let at = at.min(target.items.len()); + target.items.splice(at..at, drawn); } Ok(()) } @@ -841,8 +926,14 @@ impl Layouter<'_> { continue; } let m = sf.common.margin; - // Where the subform starts: its border and fill go round what it takes from here. - let start = self.flow_mark(); + // Its border and fill go round what it takes from here, a part on each content area. + let framed = match self.flow_mark() { + Some(part) => { + self.frames.push(Frame { x, w, margin: m, border: sf.common.border.clone(), part, parts: Vec::new(), lost: false }); + true + } + None => false, + }; self.advance(m.top); let inner_x = x + m.left; let inner_w = (w - m.horizontal()).max(1.0); @@ -862,13 +953,14 @@ impl Layouter<'_> { continue; } let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1); + self.break_before(ch)?; self.flow_block(child, inner_x, (cw, ch), &ctx, depth + 1, csib)?; } } } self.advance(m.bottom); - if let Some(b) = &sf.common.border { - self.draw_flowed_border(b, start, x, w)?; + if framed && let Some(frame) = self.frames.pop() { + self.finish_frame(frame)?; } } if sf.break_after_page { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 5cdd9ec688..aabfe175d6 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2049,8 +2049,10 @@ fn a_multi_line_field_without_a_height_grows_to_hold_its_text() { } /// A flowed subform without a fixed height gets its border and fill round all it takes, margins -/// included, drawn under its content; a flowed table too. One that doesn't fit in one content -/// area keeps no border, and the layout says so. +/// included, drawn under its content; a flowed table too. One a page break cuts between its +/// children gets a closed border round its part on each page, its bottom margin ending the first +/// part and its top margin starting the next, as another viewer draws it on a synthetic form. One +/// whose own item a page break cuts keeps no border, and the layout says so. #[test] fn a_flowed_subform_is_drawn_with_its_border_under_its_content() { let field = |name: &str| format!(r#""#); @@ -2076,10 +2078,36 @@ fn a_flowed_subform_is_drawn_with_its_border_under_its_content() { let table = r#"OneTwo"#; let form = lay(table); assert_eq!(form.pages[0].items.iter().filter(|i| matches!(i, Item::Line { .. })).count(), 4, "{:?}", form.pages[0].items); - // Thirty fields don't fit one page: no border, and one warning. + // Thirty fields: 19 fit the first page after the top margin, 11 go to the second. let many: String = (0..30).map(|i| field(&format!("f{i}"))).collect(); let form = lay(&boxed(&many)); - assert!(form.pages.len() >= 2); + assert_eq!(form.pages.len(), 2); + let fills = |page: usize| -> Vec { + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect() + }; + let (first, second) = (fills(0), fills(1)); + assert_eq!((first.len(), second.len()), (1, 1), "a part on each page"); + // Page 1: from the top (36) past 0.1in of margin and 19 fields, then 0.2in of margin. + assert!((first[0].y - 36.0).abs() < 0.01 && (first[0].bottom() - (36.0 + 7.2 + 19.0 * 36.0 + 14.4)).abs() < 0.01, "{:?}", first[0]); + // Page 2: the top margin again, 11 fields, the bottom margin. + assert!((second[0].y - 36.0).abs() < 0.01 && (second[0].bottom() - (36.0 + 7.2 + 11.0 * 36.0 + 14.4)).abs() < 0.01, "{:?}", second[0]); + let lines = |page: usize| form.pages[page].items.iter().filter(|i| matches!(i, Item::Line { .. })).count(); + assert_eq!((lines(0), lines(1)), (4, 4), "each part's border is closed"); + let f19 = form.pages[1].items.iter().find_map(|i| if let Item::Widget(w) = i { (w.name == "f19").then_some(w.rect) } else { None }).unwrap(); + assert!((f19.y - 43.2).abs() < 0.01, "the second part starts after the top margin: {}", f19.y); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // The margins come back without a border too. + let bare = format!(r#"{many}"#); + let form = lay(&bare); + let f19 = form.pages[1].items.iter().find_map(|i| if let Item::Widget(w) = i { (w.name == "f19").then_some(w.rect) } else { None }).unwrap(); + assert!((f19.y - 43.2).abs() < 0.01, "{}", f19.y); + // A text of its own longer than a page, cut across pages: no border, one warning. + let long = format!( + r#"{}"#, + "A line of the long text.\n".repeat(80) + ); + let form = lay(&boxed(&(field("a") + &long))); + assert!(form.pages.len() >= 2, "the long text runs onto another page"); assert!(form.pages.iter().all(|p| !p.items.iter().any(|i| matches!(i, Item::Fill { .. }))), "no fill drawn"); assert_eq!(form.warnings.iter().filter(|w| w.contains("bordered section")).count(), 1, "{:?}", form.warnings); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 53293bf3ec..824112f6e4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5250,7 +5250,7 @@ status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content"] -notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms that fit one content area (one that runs on past it is drawn without its border, with a layout warning), captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer; one whose own item a break cuts is drawn without its border, with a layout warning), captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From a7d86dea76e381d50bddf9d7d1b59a3ebb132a68 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:05:46 -0400 Subject: [PATCH 40/47] M12: a flowed subform's last part keeps its bottom margin inside the content area --- crates/xfa/src/layout.rs | 5 ++++- crates/xfa/src/tests.rs | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 39841c4692..a5dd72a79e 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -691,7 +691,10 @@ impl Layouter<'_> { if b.fill().is_none() && b.visible_edges() == [false; 4] { return Ok(()); } - let (_, end, _) = self.cursor(); + let (area, end, _) = self.cursor(); + // The bottom margin stays in the content area, as at a break; content that itself runs + // past it keeps the border round it. + let end = end.min(area.bottom()).max(end - f.margin.bottom); let page = self.pages.len().saturating_sub(1); if !self.on_area(&f.part) { f.lost = true; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index aabfe175d6..f20bb94ee4 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2096,6 +2096,13 @@ fn a_flowed_subform_is_drawn_with_its_border_under_its_content() { let f19 = form.pages[1].items.iter().find_map(|i| if let Item::Widget(w) = i { (w.name == "f19").then_some(w.rect) } else { None }).unwrap(); assert!((f19.y - 43.2).abs() < 0.01, "the second part starts after the top margin: {}", f19.y); assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // A bottom margin that would run past the content area stops at its edge: 39 quarter-inch + // fields end at 738, and 0.4in more would reach 766.8, past 756. + let quarter: String = (0..39).map(|i| format!(r#""#)).collect(); + let low = format!(r#"{quarter}"#); + let form = lay(&low); + let edge = form.pages[0].items.iter().find_map(|i| if let Item::Fill { rect, .. } = i { Some(rect.bottom()) } else { None }).unwrap(); + assert!((edge - 756.0).abs() < 0.01, "the box ends at the content area's bottom: {edge}"); // The margins come back without a border too. let bare = format!(r#"{many}"#); let form = lay(&bare); From cc6b042f0d985871bca950bd29029703cc3f31ff Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:15:07 -0400 Subject: [PATCH 41/47] M12: a flowed subform's repeated top margins give way to a child that fits a fresh content area only without them --- crates/xfa/src/layout.rs | 14 ++++++++++---- crates/xfa/src/tests.rs | 10 ++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index a5dd72a79e..ca0d809f97 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -649,15 +649,17 @@ impl Layouter<'_> { if self.frames.is_empty() || !placed || y + h <= rect.bottom() + 0.01 || h > rect.h + 0.01 { return Ok(()); } - self.break_frames() + self.break_frames(h) } /// Break the flowed subforms being laid out ([`Self::frames`]) across content areas, as /// another viewer breaks such a section on a synthetic form: each one's part here ends after /// its content and its bottom margin (the innermost first, each inside the next), the flow /// moves to the next content area, and each starts a new part there after its top margin - /// (the outermost first). - fn break_frames(&mut self) -> Result<(), XfaError> { + /// (the outermost first). The top margins take only what the `need` points of the child that + /// comes next leave of the new area: a child that fits a fresh area but not after the margins + /// would otherwise run past its bottom, out of its border. + fn break_frames(&mut self, need: f64) -> Result<(), XfaError> { let (rect, mut y, _) = self.cursor(); let page = self.pages.len().saturating_sub(1); let here: Vec = self.frames.iter().map(|f| self.on_area(&f.part)).collect(); @@ -671,11 +673,15 @@ impl Layouter<'_> { } } self.next_area()?; + let (rect, y, _) = self.cursor(); + let mut room = (rect.bottom() - y - need).max(0.0); for i in 0..self.frames.len() { let Some(mark) = self.flow_mark() else { break }; let Some(f) = self.frames.get_mut(i) else { break }; f.part = mark; - let top = f.margin.top; + // `max` before `min`: a NaN margin counts as none and leaves `room` a number. + let top = f.margin.top.max(0.0).min(room); + room = (room - top).max(0.0); self.advance(top); } Ok(()) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index f20bb94ee4..2bd19ddd29 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2117,6 +2117,16 @@ fn a_flowed_subform_is_drawn_with_its_border_under_its_content() { assert!(form.pages.len() >= 2, "the long text runs onto another page"); assert!(form.pages.iter().all(|p| !p.items.iter().any(|i| matches!(i, Item::Fill { .. }))), "no fill drawn"); assert_eq!(form.warnings.iter().filter(|w| w.contains("bordered section")).count(), 1, "{:?}", form.warnings); + // A field that fits a fresh page (720pt) but not after the 7.2pt top margin: the margin + // gives way, so the field ends at the content area's bottom, inside the second part. + let tall = r#""#; + let form = lay(&boxed(&(field("a") + tall))); + assert_eq!(form.pages.len(), 2); + let tall = form.pages[1].items.iter().find_map(|i| if let Item::Widget(w) = i { (w.name == "tall").then_some(w.rect) } else { None }).unwrap(); + assert!(tall.bottom() <= 756.01, "the field stays in the content area: {tall:?}"); + let part = form.pages[1].items.iter().find_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).expect("the second part"); + assert!(part.y <= tall.y + 0.01 && part.bottom() >= tall.bottom() - 0.01, "the border holds the field: {part:?} {tall:?}"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); } #[test] From 3cf6b0e589ca8f9b3c2b1cd6f8c9919b1c226101 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:21:38 -0400 Subject: [PATCH 42/47] M12: docstrings for the layout helpers and test the flowed-border change touches --- crates/xfa/src/layout.rs | 4 ++++ crates/xfa/src/tests.rs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index ca0d809f97..60c2e41ba0 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -155,6 +155,8 @@ impl Ctx { Ctx { font: self.font.apply(&c.font), para: self.para.apply(&c.para), som: self.som.clone() } } + /// [`Self::child`] for object `c`, its SOM path extended by its name and `index` among the + /// siblings of that name (an unnamed object adds nothing). fn with_som(&self, c: &Common, index: usize) -> Ctx { let mut out = self.child(c); if let Some(n) = &c.name { @@ -619,6 +621,8 @@ impl Layouter<'_> { self.new_page() } + /// The current content area, how far down it the flow is, and whether anything has been + /// placed in it; before the first page, an empty letter-size page from its top. fn cursor(&self) -> (Rect, f64, bool) { match &self.cur { Some(c) => (c.rect, c.y, c.placed), diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 2bd19ddd29..110b89629b 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2021,6 +2021,8 @@ fn repeating_rows_in_a_wrapped_container_get_the_instances_the_data_holds() { assert_eq!(form.pages.iter().flat_map(widgets).filter(|w| w.name.starts_with("what")).count(), 1); } +/// A multi-line field with a minimum height and no fixed one grows to hold its text, moving +/// what follows down; one with a fixed height keeps it. #[test] fn a_multi_line_field_without_a_height_grows_to_hold_its_text() { let place = |data: &str| { From 35716c628213c9b15e42c77f8b6f5d8170f73d00 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:29:04 -0400 Subject: [PATCH 43/47] M12: a flowed table a page break cuts between its rows keeps its border on each page --- crates/xfa/src/layout.rs | 21 ++++++++------------- crates/xfa/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 60c2e41ba0..a574c2bb3b 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -646,16 +646,6 @@ impl Layouter<'_> { self.pages.get(page).is_some_and(|p| p.items.len() > at) } - /// Before placing a child `h` tall inside flowed subforms: when it doesn't fit what is left - /// of the content area but would fit a fresh one, break there ([`Self::break_frames`]). - fn break_before(&mut self, h: f64) -> Result<(), XfaError> { - let (rect, y, placed) = self.cursor(); - if self.frames.is_empty() || !placed || y + h <= rect.bottom() + 0.01 || h > rect.h + 0.01 { - return Ok(()); - } - self.break_frames(h) - } - /// Break the flowed subforms being laid out ([`Self::frames`]) across content areas, as /// another viewer breaks such a section on a synthetic form: each one's part here ends after /// its content and its bottom margin (the innermost first, each inside the next), the flow @@ -757,13 +747,19 @@ impl Layouter<'_> { } /// Make room for `h`: stay, or move to the next content area / page. Returns whether a - /// break happened. + /// break happened. Something that fits a fresh area breaks the flowed subforms it is in with + /// it ([`Self::break_frames`]), a table's rows included; one taller than an area just moves + /// on, and those subforms are drawn without their border ([`Self::finish_frame`]). fn make_room(&mut self, h: f64) -> Result { let (rect, y, placed) = self.cursor(); if y + h <= rect.bottom() + 0.01 || !placed { return Ok(false); } - self.next_area()?; + if h <= rect.h + 0.01 { + self.break_frames(h)?; + } else { + self.next_area()?; + } Ok(true) } @@ -966,7 +962,6 @@ impl Layouter<'_> { continue; } let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1); - self.break_before(ch)?; self.flow_block(child, inner_x, (cw, ch), &ctx, depth + 1, csib)?; } } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 110b89629b..ac6366dd2f 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2131,6 +2131,40 @@ fn a_flowed_subform_is_drawn_with_its_border_under_its_content() { assert!(form.warnings.is_empty(), "{:?}", form.warnings); } +/// A flowed table a page break cuts between its rows gets a closed border round its part on +/// each page, as a flowed subform does: its margins repeat, and its overflow leader starts the +/// second part after the top margin. +#[test] +fn a_flowed_table_cut_between_its_rows_keeps_its_border_on_each_page() { + let row = |name: &str, text: &str| { + format!( + r#"{text}-"# + ) + }; + let rows: String = (0..40).map(|i| row(&format!("r{i}"), &format!("Row {i}"))).collect(); + let table = format!( + r#"{}{rows}"#, + row("head", "Head") + ); + let body = format!(r#"{table}"#); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 2); + let fills = |page: usize| -> Vec { + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect() + }; + let lines = |page: usize| form.pages[page].items.iter().filter(|i| matches!(i, Item::Line { .. })).count(); + let (first, second) = (fills(0), fills(1)); + assert_eq!((first.len(), second.len()), (1, 1), "a part on each page"); + assert_eq!((lines(0), lines(1)), (4, 4), "each part's border is closed"); + // Page 1: 0.1in of margin, the leader and 32 rows of 0.3in fill the area to its bottom. + assert!((first[0].y - 36.0).abs() < 0.01 && (first[0].bottom() - 756.0).abs() < 0.01, "{:?}", first[0]); + // Page 2: the top margin again, the leader, the other 8 rows, the bottom margin. + assert!((second[0].y - 36.0).abs() < 0.01 && (second[0].bottom() - (36.0 + 7.2 + 9.0 * 21.6 + 14.4)).abs() < 0.01, "{:?}", second[0]); + let head = form.pages[1].items.iter().find_map(|i| if let Item::Text(t) = i { (t.text == "Head").then_some(t.baseline) } else { None }); + assert!(head.is_some_and(|b| b > 43.2 && b < 64.8), "the leader starts the second part after its top margin: {head:?}"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); +} + #[test] fn a_page_break_keeps_a_fields_border_with_the_field() { // 6in of heading, then a bordered 5in field: it doesn't fit the 10in area and moves to From cafd507f299e171cfebf0e2bb8e889e756197c5b Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:51:54 -0400 Subject: [PATCH 44/47] M12: page breaks of a section's own keep the border around it, a leader is given room with its row, and what runs off a page costs its section the border --- crates/xfa/src/layout.rs | 82 ++++++++++++++++++++++++++-------------- crates/xfa/src/tests.rs | 66 ++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 29 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index a574c2bb3b..4ffba18100 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -200,8 +200,9 @@ struct Frame { border: Option, part: FlowMark, parts: Vec<(usize, usize, Rect)>, - /// The flow left a part's content area other than at a break between children (an item cut - /// across pages, a table's own break): the border is left out. + /// Something inside didn't fit its content area: an item cut across areas, or one placed past + /// the area's bottom (a table row taller than an area, an overflow leader and the row after it + /// that don't fit together). The border is left out ([`Layouter::finish_frame`]). lost: bool, } @@ -650,10 +651,11 @@ impl Layouter<'_> { /// another viewer breaks such a section on a synthetic form: each one's part here ends after /// its content and its bottom margin (the innermost first, each inside the next), the flow /// moves to the next content area, and each starts a new part there after its top margin - /// (the outermost first). The top margins take only what the `need` points of the child that - /// comes next leave of the new area: a child that fits a fresh area but not after the margins - /// would otherwise run past its bottom, out of its border. - fn break_frames(&mut self, need: f64) -> Result<(), XfaError> { + /// (the outermost first). The top margins take only what the `need` points of what comes next + /// leave of the new area: a child that fits a fresh area but not after the margins would + /// otherwise run past its bottom, out of its border. `to_page` moves to a new page instead (a + /// child's own page break), which breaks the subforms around it the same way. + fn break_frames(&mut self, need: f64, to_page: bool) -> Result<(), XfaError> { let (rect, mut y, _) = self.cursor(); let page = self.pages.len().saturating_sub(1); let here: Vec = self.frames.iter().map(|f| self.on_area(&f.part)).collect(); @@ -666,7 +668,11 @@ impl Layouter<'_> { f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, y - f.part.y))); } } - self.next_area()?; + if to_page { + self.new_page()?; + } else { + self.next_area()?; + } let (rect, y, _) = self.cursor(); let mut room = (rect.bottom() - y - need).max(0.0); for i in 0..self.frames.len() { @@ -683,17 +689,16 @@ impl Layouter<'_> { /// Close flowed subform `f` once its content is placed: its last part ends where the flow /// is, and its border (edges and fill) is drawn round each part, closed on every side, under - /// that part's content. One whose flow left a content area other than at a break between its - /// children (an item of its own cut across pages) is drawn without its border, and the layout - /// says so. + /// that part's content. One holding something that didn't fit its content area ([`Frame::lost`]) + /// is drawn without its border, and the layout says so. fn finish_frame(&mut self, mut f: Frame) -> Result<(), XfaError> { let Some(b) = f.border.take() else { return Ok(()) }; if b.fill().is_none() && b.visible_edges() == [false; 4] { return Ok(()); } let (area, end, _) = self.cursor(); - // The bottom margin stays in the content area, as at a break; content that itself runs - // past it keeps the border round it. + // The bottom margin stays in the content area, as at a break; content that itself ran + // past it has already cost the section its border ([`Self::overflowed`]). let end = end.min(area.bottom()).max(end - f.margin.bottom); let page = self.pages.len().saturating_sub(1); if !self.on_area(&f.part) { @@ -702,7 +707,7 @@ impl Layouter<'_> { f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, end - f.part.y))); } if f.lost { - self.warn_once("a bordered section whose own item a page break cuts is drawn without its border"); + self.warn_once("a bordered section holding an item that doesn't fit on its page is drawn without its border"); return Ok(()); } // The last part first: a part's place on its page stays where it was found. @@ -744,19 +749,33 @@ impl Layouter<'_> { c.y += dy.max(0.0); c.placed = true; } + let (_, y, _) = self.cursor(); + self.overflowed(y); } - /// Make room for `h`: stay, or move to the next content area / page. Returns whether a - /// break happened. Something that fits a fresh area breaks the flowed subforms it is in with - /// it ([`Self::break_frames`]), a table's rows included; one taller than an area just moves - /// on, and those subforms are drawn without their border ([`Self::finish_frame`]). - fn make_room(&mut self, h: f64) -> Result { + /// Something was placed down to `bottom`: past the content area's bottom, the flowed subforms + /// it is inside can't hold it in a part, and are drawn without their border. + fn overflowed(&mut self, bottom: f64) { + let (rect, _, _) = self.cursor(); + if bottom > rect.bottom() + 0.01 { + for f in &mut self.frames { + f.lost = true; + } + } + } + + /// Make room for `h`, with `after` more drawn before it should it move (a table's overflow + /// leader): stay, or move to the next content area / page. Returns whether a break happened. + /// Something that fits a fresh area breaks the flowed subforms it is in with it + /// ([`Self::break_frames`]), a table's rows included; one taller than an area just moves on, + /// and those subforms are drawn without their border ([`Self::finish_frame`]). + fn make_room(&mut self, h: f64, after: f64) -> Result { let (rect, y, placed) = self.cursor(); if y + h <= rect.bottom() + 0.01 || !placed { return Ok(false); } if h <= rect.h + 0.01 { - self.break_frames(h)?; + self.break_frames(h + after, false)?; } else { self.next_area()?; } @@ -769,7 +788,7 @@ impl Layouter<'_> { fn flow_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { let (rect, y, _) = self.cursor(); if y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { - self.make_room(h)?; + self.make_room(h, 0.0)?; } let (rect, y, _) = self.cursor(); let first = self.pages.last().map_or(0, |p| p.items.len()); @@ -888,6 +907,7 @@ impl Layouter<'_> { c.y = (end - shift).min(c.rect.bottom()).max(c.y); c.placed = true; } + self.overflowed(end - shift); return Ok(()); } rest = below; @@ -914,7 +934,7 @@ impl Layouter<'_> { let Node::Subform(sf) = node else { return Ok(()) }; self.check_measures()?; if sf.break_before_page && self.page_has_flow() { - self.new_page()?; + self.break_frames(0.0, true)?; } // Data adds instances of a repeating subform (rows added in another viewer). let instances = self.instances(node, &ctx.som); @@ -972,7 +992,7 @@ impl Layouter<'_> { } } if sf.break_after_page { - self.new_page()?; + self.break_frames(0.0, true)?; } Ok(()) } @@ -989,6 +1009,12 @@ impl Layouter<'_> { let cols = self.columns(sf, inner_w); let leader = sf.overflow_leader.as_ref().and_then(|name| sf.children.iter().position(|c| c.common().name.as_deref() == Some(name) && is_row(c))); + // The overflow leader starts each part after the first, before the row that moved. + let lead = leader.and_then(|li| match sf.children.get(li) { + Some(Node::Subform(l)) => Some((li, l.as_ref())), + _ => None, + }); + let lead_h = lead.map_or(0.0, |(_, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1)); let sibs = SomIndices::of(&sf.children); for (ci, child) in sf.children.iter().enumerate() { if !child.common().presence.occupies() { @@ -1003,15 +1029,13 @@ impl Layouter<'_> { self.warn_truncated(child, &ctx.som, instances); for inst in 0..instances { let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); - if self.make_room(h)? - && let Some(li) = leader - && li != ci - && let Some(Node::Subform(l)) = sf.children.get(li) + let led = lead.filter(|(li, _)| *li != ci); + if self.make_room(h, led.map_or(0.0, |_| lead_h))? + && let Some((li, l)) = led { - let lh = self.row_height(l, &cols, inner_w, ctx, depth + 1); let (_, y, _) = self.cursor(); - self.draw_row(l, &cols, Rect::new(x, y, inner_w, lh), ctx, depth + 1, sibs.at(li))?; - self.place(lh); + self.draw_row(l, &cols, Rect::new(x, y, inner_w, lead_h), ctx, depth + 1, sibs.at(li))?; + self.place(lead_h); } let (_, y, _) = self.cursor(); self.draw_row(row, &cols, Rect::new(x, y, inner_w, h), ctx, depth + 1, csib + inst)?; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index ac6366dd2f..da88157f4d 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2129,6 +2129,11 @@ fn a_flowed_subform_is_drawn_with_its_border_under_its_content() { let part = form.pages[1].items.iter().find_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).expect("the second part"); assert!(part.y <= tall.y + 0.01 && part.bottom() >= tall.bottom() - 0.01, "the border holds the field: {part:?} {tall:?}"); assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // The same field first in the section, at the top of the page: after the section's own top + // margin it runs past the bottom, so the section has no border round it, and says so. + let form = lay(&boxed(r#""#)); + assert!(form.pages.iter().all(|p| !p.items.iter().any(|i| matches!(i, Item::Fill { .. }))), "no fill drawn"); + assert_eq!(form.warnings.iter().filter(|w| w.contains("bordered section")).count(), 1, "{:?}", form.warnings); } /// A flowed table a page break cuts between its rows gets a closed border round its part on @@ -2165,6 +2170,67 @@ fn a_flowed_table_cut_between_its_rows_keeps_its_border_on_each_page() { assert!(form.warnings.is_empty(), "{:?}", form.warnings); } +/// A flowed table's overflow leader and the row that moved are given room together on the new +/// page; a leader and row too tall for any page, and a first row taller than its page, cost the +/// table its border, with the warning, rather than a border round what runs off the page. +#[test] +fn a_tables_leader_and_oversized_rows_fit_its_border_or_lose_it() { + let row = |name: &str, h: &str| { + format!(r#"{name}"#) + }; + let table = |rows: &str| { + format!( + r#"{}{rows}"#, + row("head", "0.3in") + ) + }; + let lay = |rows: &str| layout_xml(&one_area_form(r#"layout="tb""#, &table(rows))).unwrap(); + let fills = |form: &Form, page: usize| -> Vec { + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect() + }; + let small: String = (0..10).map(|i| row(&format!("r{i}"), "0.3in")).collect(); + // A 695pt row after ten small ones moves; it and the 21.6pt leader leave 3.4pt of the 720pt + // page for the top margin, so the row ends at the content area's bottom, in the border. + let form = lay(&(small.clone() + &row("big", "695pt"))); + assert_eq!(form.pages.len(), 2); + let part = fills(&form, 1); + assert!(part.len() == 1 && (part[0].y - 36.0).abs() < 0.01 && (part[0].bottom() - 756.0).abs() < 0.01, "{part:?}"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // 710pt and the leader don't fit any page: no border, one warning. + let form = lay(&(small + &row("big", "710pt"))); + assert!(form.pages.iter().enumerate().all(|(p, _)| fills(&form, p).is_empty()), "no fill drawn"); + assert_eq!(form.warnings.iter().filter(|w| w.contains("bordered section")).count(), 1, "{:?}", form.warnings); + // A first row taller than the page: no border round it, one warning. + let form = lay(&row("tall", "800pt")); + assert!(fills(&form, 0).is_empty(), "{:?}", fills(&form, 0)); + assert_eq!(form.warnings.iter().filter(|w| w.contains("bordered section")).count(), 1, "{:?}", form.warnings); +} + +/// A child's own page break (breakBefore, breakAfter) breaks the flowed subform around it as a +/// break between children does: a closed part on each page, no warning. +#[test] +fn a_childs_page_break_keeps_the_border_around_it_on_each_page() { + let field = |name: &str| format!(r#""#); + let body = format!( + r#"{}{}{}{}"#, + field("a"), + field("b"), + field("c"), + field("d") + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 3, "a, then b and c, then d"); + for (page, fields) in [(0, 1.0), (1, 2.0), (2, 1.0)] { + let fills: Vec = + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect(); + let lines = form.pages[page].items.iter().filter(|i| matches!(i, Item::Line { .. })).count(); + assert_eq!((fills.len(), lines), (1, 4), "page {}: one closed part", page + 1); + // The top margin, its fields, the bottom margin. + assert!((fills[0].y - 36.0).abs() < 0.01 && (fills[0].h - (7.2 + fields * 36.0 + 14.4)).abs() < 0.01, "page {}: {:?}", page + 1, fills[0]); + } + assert!(form.warnings.is_empty(), "{:?}", form.warnings); +} + #[test] fn a_page_break_keeps_a_fields_border_with_the_field() { // 6in of heading, then a bordered 5in field: it doesn't fit the 10in area and moves to From 4d190aaff87ab5a53f74de398cb4209f62a6b4e3 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:52:53 -0400 Subject: [PATCH 45/47] M12: the parity note and test say which sections lose their border --- crates/xfa/src/tests.rs | 2 +- parity/acrobat-features.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index da88157f4d..e87e27ef08 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2054,7 +2054,7 @@ fn a_multi_line_field_without_a_height_grows_to_hold_its_text() { /// included, drawn under its content; a flowed table too. One a page break cuts between its /// children gets a closed border round its part on each page, its bottom margin ending the first /// part and its top margin starting the next, as another viewer draws it on a synthetic form. One -/// whose own item a page break cuts keeps no border, and the layout says so. +/// holding an item that doesn't fit on its page keeps no border, and the layout says so. #[test] fn a_flowed_subform_is_drawn_with_its_border_under_its_content() { let field = |name: &str| format!(r#""#); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 824112f6e4..e96bf6d65b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5250,7 +5250,7 @@ status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content"] -notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer; one whose own item a break cuts is drawn without its border, with a layout warning), captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From 2fc251193ea57187a48b424c09dc775dee93d340 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:16:10 -0400 Subject: [PATCH 46/47] M12: a test that a section whose first child moves to the next page takes its border along --- crates/xfa/src/tests.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index e87e27ef08..bb127540a0 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2129,6 +2129,18 @@ fn a_flowed_subform_is_drawn_with_its_border_under_its_content() { let part = form.pages[1].items.iter().find_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).expect("the second part"); assert!(part.y <= tall.y + 0.01 && part.bottom() >= tall.bottom() - 0.01, "the border holds the field: {part:?} {tall:?}"); assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // A section that starts near a page's bottom, where its first field doesn't fit: the field + // moves to the next page whole, and the border starts there with it, none left behind. + let spacer = r#""#; + let form = lay(&format!("{spacer}{}", boxed(&(field("a") + &field("b"))))); + assert_eq!(form.pages.len(), 2); + let fills = |page: usize| -> Vec { + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect() + }; + assert!(fills(0).is_empty(), "no border left behind on page 1: {:?}", fills(0)); + let moved = fills(1); + assert!(moved.len() == 1 && (moved[0].y - 36.0).abs() < 0.01 && (moved[0].h - (7.2 + 2.0 * 36.0 + 14.4)).abs() < 0.01, "{moved:?}"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); // The same field first in the section, at the top of the page: after the section's own top // margin it runs past the bottom, so the section has no border round it, and says so. let form = lay(&boxed(r#""#)); From 52f41f50d117ed55a3293eb362f8e49e563a5706 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:34:49 +0000 Subject: [PATCH 47/47] Integration: cargo fmt --- crates/engine/src/js.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 4d502f0a9e..e275a8d313 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -183,13 +183,7 @@ impl JsRunner { // Packets alone are not enough: static XFA and an unlaid-out shell keep their scripts. let xfa_active = pdfcraft_xfa::xfa_shape(cos).is_some() && pdfcraft_xfa::existing_layout(cos).is_some(); let doc_scripts = if xfa_active { Vec::new() } else { pdfcraft_forms::document_scripts(cos) }; - JsRunner { - doc, - doc_scripts, - output: JsOutput::default(), - cos: cos.clone(), - displays: std::collections::HashMap::new(), - } + JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone(), displays: std::collections::HashMap::new() } } }