diff --git a/ROADMAP.md b/ROADMAP.md index 7ae1dc8ccc..d82a535288 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,6 +55,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged) @@ -252,6 +253,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged. - **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved. +- **2026-10-18 (M12, #2589):** Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged. - **2026-10-18 (M7, #3187):** Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged. - **2026-10-18 (M12, #2569):** Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged. - **2026-10-18 (M12, #2572):** Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged. diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index a7334f2069..4996830639 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() }; @@ -642,6 +649,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(); @@ -650,6 +659,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 { @@ -661,6 +672,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": p.id() }), } } @@ -669,7 +681,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 64d7f2ea8d..2d2cf41f30 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1126,7 +1126,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), 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/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index f2b135d9df..443328af73 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6148,6 +6148,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()); } @@ -6658,6 +6664,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"); @@ -6991,6 +7024,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 aa43d3dbac..d221e773de 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}; @@ -19,25 +19,63 @@ 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 { + /// 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() } + /// 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()); - 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, 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()), + } + } 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.doc_errors.contains(e) { + self.doc_errors.push(e.clone()); + self.errors.push(e.clone()); + } + } } pub(crate) fn append(&mut self, other: 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, even one worded like a document script's. + let mut docs = other.doc_errors; + for e in other.errors { + 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); + } } } @@ -139,13 +177,13 @@ impl JsRunner { info: info(cos), xfa: pdfcraft_xfa::xfa_shape(cos).is_some(), }; - JsRunner { - doc, - doc_scripts: pdfcraft_forms::document_scripts(cos), - output: JsOutput::default(), - cos: cos.clone(), - displays: std::collections::HashMap::new(), - } + // 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(), displays: std::collections::HashMap::new() } } } @@ -453,6 +491,32 @@ 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: 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 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] 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/engine/src/lib.rs b/crates/engine/src/lib.rs index c925601204..9f169171af 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2610,10 +2610,23 @@ 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), + }; + 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() { 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 c61394ec2d..c90ab8fcdb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4322,6 +4322,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) }; @@ -4335,7 +4340,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(); @@ -4397,6 +4402,99 @@ 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 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); @@ -4906,6 +5004,61 @@ fn an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box() { assert!(doc.field_text_box("no such field", 0).is_none() && doc.field_text_box(&field("code").name, 5).is_none()); } +/// 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 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))); @@ -7638,6 +7791,100 @@ 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(); + 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")); +} + +/// 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")); +} + +/// 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##" + + + +"##; + 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); +} + +/// 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); + 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]); +} + /// A page whose forms go past the reading limit and whose picture goes past the image budget is /// listed once among the truncated pages. #[test] diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 6166426bbf..2f98efdb04 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, } @@ -628,6 +629,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)); @@ -858,6 +860,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/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 875b8c8e14..15584ec77b 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -399,10 +399,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))); } @@ -468,6 +472,83 @@ 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 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); + 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); + 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 = 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 { "" })); + (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/lib.rs b/crates/forms/src/lib.rs index 777caa5998..17d67e4736 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), } @@ -1050,7 +1054,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 0c2daeb6de..b366572930 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -500,6 +500,58 @@ fn an_edit_keeps_the_longest_part_that_fits_across_a_change_of_font() { assert_eq!(caret, 11); } +/// 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 (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| { + 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 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", "underlined"] { + 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}"); + // 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 /// empty value clears it. #[test] @@ -1383,7 +1435,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())), diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 4f31d04d34..7b0d0e88a7 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -1948,7 +1948,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 { // The length of the month from chrono (the first of the next month, less the first of this // one). An invalid month keeps the old 31-day default. // Month lengths repeat every 400 years, so the year within its cycle is enough (and always @@ -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/lib.rs b/crates/js/src/lib.rs index 13bdc4ba36..7f463a48bd 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -13,8 +13,8 @@ //! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`, //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, //! `setFocus`. -//! - `app` (`alert`, `beep`, `response`, `launchURL`, `viewerType`, `viewerVersion`, -//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`), `console` (`println`, +//! - `app` (`alert`, `beep`, `response`, `launchURL`, `execMenuItem`, `viewerType`, `viewerVersion`, +//! `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 @@ -28,7 +28,7 @@ use std::collections::BTreeSet; use std::sync::Arc; use boa_engine::context::HostHooks; -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::realm::Realm; @@ -164,6 +164,36 @@ 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 { + /// 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, +} + +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. @@ -181,6 +211,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 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, } @@ -626,6 +659,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().alert(m); @@ -633,17 +668,49 @@ 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().request(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().request(Request::LaunchUrl(u)); Ok(JsValue::undefined()) } +/// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print +/// and Save As. Other items do nothing. +pub(crate) fn menu_item(name: &str) -> 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, + // 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, + }) +} + +/// `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) { + host(ctx)?.borrow_mut().requests.push(r); + } + 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()) @@ -885,6 +952,324 @@ 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 { + /// 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) + } + + /// 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), +} + +/// 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(); + // 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 { + /// 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(), + 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 { @@ -950,6 +1335,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")]), @@ -976,8 +1363,10 @@ fn color_object(ctx: &mut Context) -> JsObject { /// The XFA version scripts see as `xfa_version`. const XFA_VERSION: f64 = 3.5; -/// Define the globals every script sees: the document's functions and properties (`getField`, -/// `numPages`, `info`, the XFA version), and `app`, `util`, `console`, `display` and `color`. +/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, +/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, +/// `documentFileName`, `info`, the XFA version), 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)), @@ -1025,6 +1414,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) @@ -1041,6 +1431,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) @@ -1204,16 +1595,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}")), @@ -1221,7 +1627,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) => { @@ -1277,12 +1698,18 @@ 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); ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?; - for d in doc_scripts { - ctx.eval(Source::from_bytes(d))?; + // One document script that throws (calling a method PdfCraft lacks, say) must not keep + // every field script in the form from running: what it defined before the error, and + // the other scripts, stay available (#3940). + for &(i, d) in doc_scripts { + if let Err(e) = ctx.eval(Source::from_bytes(d)) { + doc_errors.push((i, doc_error(i, &e))); + } } let r = ctx.eval(Source::from_bytes(script))?; if !r.is_undefined() { @@ -1299,6 +1726,8 @@ 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 b2e866faef..06d699bcb2 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -297,6 +297,61 @@ fn document_level_functions_are_available() { assert_eq!(o.value, "8"); } +/// #3940: a document script that throws after defining a helper (here calling an Acrobat method +/// PdfCraft lacks) still leaves the helper, and the later scripts, to the field script. +#[test] +fn a_failing_document_script_does_not_stop_the_field_script() { + let docs = [ + "function double(x) { return x * 2; }\nthis.getField('qty').buttonSetIcon(null);\nvar after = 1;".to_string(), + "function plus(x) { return x + 1; }".to_string(), + "this is not javascript".to_string(), + ]; + let o = run( + "event.value = plus(double(getField('qty').value));", + &Event::field("Calculate", "total", ""), + &doc(), + &fields(), + &docs, + Limits::default(), + ); + assert_eq!((o.error.as_deref(), o.value.as_str()), (None, "9")); + assert_eq!(o.doc_errors.len(), 2, "{:?}", o.doc_errors); + assert!(o.doc_errors[0].starts_with("document script 1: TypeError"), "{:?}", o.doc_errors); + assert!(o.doc_errors[1].starts_with("document script 3: SyntaxError"), "{:?}", o.doc_errors); + // The failing script stopped where it failed. + let o = run("event.value = typeof after;", &Event::field("Calculate", "total", ""), &doc(), &fields(), &docs, Limits::default()); + assert_eq!(o.value, "undefined"); + // Working document scripts report nothing. + 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")); @@ -336,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] @@ -805,6 +862,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] @@ -1328,6 +1393,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(), @@ -1339,6 +1406,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() }); @@ -1378,6 +1448,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 @@ -1398,6 +1470,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()); @@ -1455,6 +1530,249 @@ fn shared_field_snapshots_isolate_writes_errors_and_script_globals() { assert!(read.changed.is_empty()); } +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. + + /// 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(&[ + ("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"), + ]); + } + + /// 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(&[ + ("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"), + ]); + } + + /// 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(&[ + ("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"), + ]); + } + + /// 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(&[ + ("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"), + ]); + } + + /// 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(&[ + ("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"), + ]); + } + + /// 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( + "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. + + /// 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(&[ + ("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"); + } + + /// A format of 50,000 literal letters and one of 100,000 fields, with texts to match, are read + /// 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. + 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(10), "{:?}", 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 + // 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}"); + } + } +} + +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( + "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, + ] + ); + } + + /// `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() }; + 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); + } +} + /// The check XFA forms run on opening: without both globals, or with a version below 2.8, it /// alerts that the viewer can't show the form. const XFA_CHECK: &str = "if (typeof(xfa_installed) == 'undefined' || typeof(xfa_version) == 'undefined' || xfa_version < 2.8) app.alert('old viewer'); \ diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 45abeeb026..42b1688ed5 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)] @@ -115,6 +115,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), @@ -219,7 +221,7 @@ fn payload(e: &XfaEffect) -> usize { XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), - XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout => 0, + XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0, } } @@ -1131,39 +1133,50 @@ 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)?; effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; Ok(JsValue::undefined()) } +/// `xfa.host.print(…)`: asks to print; its arguments are ignored. fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> 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() { @@ -1182,11 +1195,14 @@ 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()) @@ -1233,6 +1249,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)?; @@ -1343,6 +1362,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/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5e52d1d980..54f4265826 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4847,8 +4847,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/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 7bc3dd0444..ecc87736d6 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(_) => {} } } @@ -88,7 +99,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 +145,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}")), diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 398bc1aeb2..14f08b7b11 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -1373,6 +1373,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(); @@ -1455,3 +1458,20 @@ fn mode_tabs_keep_the_hand_over_a_text_field_scrolled_under_them() { click_at(&mut h, convert); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the field's editor"); } + +/// 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(); + 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/crates/ui-egui/tests/it/xfa.rs b/crates/ui-egui/tests/it/xfa.rs index cf0fc193b1..d3d8240644 100644 --- a/crates/ui-egui/tests/it/xfa.rs +++ b/crates/ui-egui/tests/it/xfa.rs @@ -62,6 +62,14 @@ fn a_dynamic_xfa_form_is_laid_out_and_filled() { h.get_by_label("Hide field highlights"); } +#[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)); + // 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] 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/README.md b/crates/xfa/README.md index 7616625f36..23a6ef0d0e 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -70,6 +70,10 @@ be measured exponentially often in their nesting. A measurement budget backs thi field changes, and click for buttons. Every generated copy of a page-master button resolves its own PDF field to the shared XFA click script, so clicking an early copy works as well as the last, including through `js_run` and after save/reopen. + 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/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index a0307a6944..b76b3efa1a 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -505,6 +505,29 @@ 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; `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##" + + + +"## + .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. @@ -597,6 +620,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/layout.rs b/crates/xfa/src/layout.rs index 8efee1029e..3657bdbe2e 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -37,15 +37,52 @@ 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] }, - 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, @@ -157,6 +194,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 { @@ -179,6 +218,33 @@ 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, +} + +/// 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)>, + /// 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, +} + struct Cursor { area: usize, content: usize, @@ -213,6 +279,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 @@ -258,6 +327,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()?; @@ -336,7 +406,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, } } @@ -371,6 +441,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), } } @@ -382,6 +469,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); @@ -403,7 +494,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()) } @@ -523,7 +614,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() { @@ -591,6 +682,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), @@ -598,6 +691,111 @@ 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()) }) + } + + /// 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) + } + + /// 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). 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(); + 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))); + } + } + 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() { + let Some(mark) = self.flow_mark() else { break }; + let Some(f) = self.frames.get_mut(i) else { break }; + f.part = mark; + // `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(()) + } + + /// 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 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 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) { + 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 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. + 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)?; + 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(()) + } + /// 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 { @@ -611,16 +809,36 @@ 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. - 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); } - self.next_area()?; + if h <= rect.h + 0.01 { + self.break_frames(h + after, false)?; + } else { + self.next_area()?; + } Ok(true) } @@ -630,7 +848,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()); @@ -749,6 +967,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; @@ -775,7 +994,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); @@ -796,6 +1015,14 @@ impl Layouter<'_> { continue; } let m = sf.common.margin; + // 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); @@ -820,9 +1047,12 @@ impl Layouter<'_> { } } self.advance(m.bottom); + if framed && let Some(frame) = self.frames.pop() { + self.finish_frame(frame)?; + } } if sf.break_after_page { - self.new_page()?; + self.break_frames(0.0, true)?; } Ok(()) } @@ -839,6 +1069,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() { @@ -853,15 +1089,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)?; @@ -1405,11 +1639,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/lib.rs b/crates/xfa/src/lib.rs index 93e668257f..7fe372d53a 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, field_grows_with_text, fields_by_som, form_tree, has_scripts, overrides, refresh_value, rerender, set_overrides, template_of, @@ -165,6 +167,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/model.rs b/crates/xfa/src/model.rs index 793950bfb1..0fac8db18e 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. @@ -584,7 +604,7 @@ pub enum Value { slope_up: bool, edge: Edge, }, - Rectangle(Border), + Rectangle(Box), } impl Value { @@ -636,6 +656,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: 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 5004651684..e312d5310f 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. @@ -201,7 +217,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 +226,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 +272,7 @@ fn parse_rich(body: XmlNode) -> RichText { size: style.size, h_scale: style.h_scale, embed: None, + link: style.link.clone(), }, ); } @@ -285,6 +304,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(std::sync::Arc::from(href)); + } + } _ => {} } let embed = c.attributes().find(|a| a.name() == "embed").map(|a| a.value().trim_start_matches('#').to_string()); @@ -300,6 +326,7 @@ fn parse_rich(body: XmlNode) -> RichText { size: st.size, h_scale: st.h_scale, embed: Some(e), + link: st.link.clone(), }, ); } @@ -378,7 +405,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 fa1f1db356..8e930c8f46 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -8,9 +8,9 @@ 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}; +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"; @@ -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(); @@ -150,16 +221,27 @@ 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 /// 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 @@ -273,6 +355,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; @@ -580,8 +665,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(); @@ -589,11 +674,27 @@ 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() { + // A link's pieces join only across text and the underline drawn just before it: a fill, + // 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, .. } => 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 { + 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()); } + Item::Rounded(r) => content.extend(rounded(r, h).bytes()), Item::Line { from, to, width, color, dashed } => { content.extend( format!( @@ -635,6 +736,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 }; @@ -674,6 +778,108 @@ 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.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 (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 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 @@ -799,10 +1005,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). @@ -988,21 +1196,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. @@ -1049,6 +1243,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/picture.rs b/crates/xfa/src/picture.rs new file mode 100644 index 0000000000..3ddb392114 --- /dev/null +++ b/crates/xfa/src/picture.rs @@ -0,0 +1,65 @@ +//! 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, 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 { + 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; + } + // 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 +/// 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/script.rs b/crates/xfa/src/script.rs index cb555dc81d..c8d506495e 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -601,6 +601,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 6231cbf78a..8421c6554e 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -395,6 +395,223 @@ fn fields_that_dont_scroll_are_written_with_do_not_scroll() { assert_eq!(no_scroll, expected); } +/// 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); +} + +/// 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 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] +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]); +} + +/// 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 level_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"); + 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, 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 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(); + 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:?}"); +} + #[test] fn documents_without_xfa_are_not_touched() { let mut doc = Document::new_empty(); @@ -2066,6 +2283,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| { @@ -2093,6 +2312,297 @@ 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}"); +} + +/// 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 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 +/// 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#""#); + 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: 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_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); + // 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); + 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); + // 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); + // 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#""#)); + 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 +/// 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); +} + +/// 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 @@ -2545,6 +3055,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; @@ -2742,6 +3283,40 @@ 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(); + 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"); +} + +/// 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, 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(); + 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"); + // 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"); +} + /// A word longer than its line is broken by character in work linear in its length. #[test] fn a_long_word_breaks_in_linear_work() { @@ -2913,3 +3488,44 @@ fn rendering_keeps_the_forms_own_font_objects() { assert_eq!(fonts.as_dict().unwrap().reference(b"Mine"), Some(mine), "the form's own font object"); assert!(fonts.as_dict().unwrap().reference(b"Helv").is_some(), "and the layout's fonts as objects"); } + + +/// 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 e1f4c548b0..77076b37e0 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 } } @@ -153,6 +155,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, shared with the + /// link's other spans. + pub link: Option>, } /// A laid-out block of text. @@ -168,6 +173,9 @@ struct Word { text: String, face: Face, width: f64, + /// 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 @@ -224,12 +232,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 +285,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 +298,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 +343,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/docs/roadmap.md b/docs/roadmap.md index a1ff83408f..dcf66a1ba6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -75,6 +75,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged) @@ -187,7 +188,7 @@ 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). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. | | 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). Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). 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. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
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: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | +| 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. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
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: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | 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. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
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
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | | 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. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 37e0ff6d33..e4771f21fb 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 @@ -4463,8 +4463,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" @@ -5152,8 +5152,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" @@ -5176,8 +5176,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/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed"] -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; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent." +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/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them"] +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/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent." [[feature]] id = "form.js-console" @@ -5260,8 +5260,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/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/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "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", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "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", "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", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content"] +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. 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, rounded and inverted border corners, 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; 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, 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"