mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 06:51:50 -06:00
Merge track r3-t03
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<pdfcraft_engine::ocr::OcrSettings> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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::<usize>(), "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<Value> {
|
||||
use pdfcraft_engine::ocr;
|
||||
let dirs: Vec<String> = 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<Value> = 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<Value> {
|
||||
|
||||
@@ -1126,7 +1126,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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." } }),
|
||||
|
||||
@@ -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##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<draw name="help" w="6in"><font typeface="Arial" size="10pt"/><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Questions? Email <a href="mailto:forms@example.com">forms@example.com</a>.</p></body></exData></value></draw>
|
||||
<field name="name" w="3in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
</subform></template></xdp:xdp>"##;
|
||||
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<String> {
|
||||
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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
+74
-10
@@ -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<Request>,
|
||||
/// Script errors (as Acrobat's console shows them).
|
||||
pub errors: Vec<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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()]);
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
+248
-1
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
"<< /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#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb"><pageSet><pageArea name="page">
|
||||
<contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/>
|
||||
</pageArea></pageSet><field name="text" w="200pt" h="30pt"><ui><textEdit/></ui></field></subform></template>"#;
|
||||
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("<form1><page1><name>Test</name></page1></form1>"),
|
||||
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##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
|
||||
<field name="round" w="1in" h="0.3in"><ui><checkButton shape="round"/></ui><items><integer>1</integer><integer>0</integer></items></field>
|
||||
<field name="square" w="1in" h="0.3in"><ui><checkButton/></ui><items><integer>1</integer><integer>0</integer></items></field>
|
||||
</subform></template></xdp:xdp>"##;
|
||||
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##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<field name="files" w="2in" h="0.3in"><ui><button/></ui><caption><value><text>Attachments</text></value></caption>
|
||||
<event activity="click"><script contentType="application/x-javascript">app.execMenuItem("ShowHideFileAttachment");</script></event></field>
|
||||
<field name="note" w="2in" h="0.3in"><ui><textEdit/></ui>
|
||||
<event activity="exit"><script contentType="application/x-javascript">app.execMenuItem("ShowHideFileAttachment");</script></event></field>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##;
|
||||
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]
|
||||
|
||||
@@ -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<pdfcraft_forms::FieldValue> {
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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<String> = 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())),
|
||||
|
||||
@@ -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)
|
||||
|
||||
+440
-11
@@ -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<Request>,
|
||||
/// The script's error (syntax error, exception, or a limit reached), if it failed.
|
||||
pub error: Option<String>,
|
||||
/// 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<String>,
|
||||
/// The script's completion value (what the console prints), unless undefined.
|
||||
pub result: Option<String>,
|
||||
}
|
||||
@@ -626,6 +659,8 @@ fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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<JsValue> {
|
||||
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<JsValue>
|
||||
Ok(JsValue::from(1))
|
||||
}
|
||||
|
||||
/// `app.beep()`: queues a request to beep.
|
||||
fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
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<JsValue> {
|
||||
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<Request> {
|
||||
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<JsValue> {
|
||||
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<JsValue> {
|
||||
// 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<JsValue>
|
||||
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<ScanPart> {
|
||||
let chars: Vec<char> = fmt.chars().collect();
|
||||
let mut parts: Vec<ScanPart> = 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<Vec<ScanToken<'_>>> {
|
||||
// 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<i32>,
|
||||
mo: Option<u32>,
|
||||
d: Option<u32>,
|
||||
h: Option<u32>,
|
||||
mi: Option<u32>,
|
||||
sec: Option<u32>,
|
||||
pm: Option<bool>,
|
||||
/// 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<Scanned> {
|
||||
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<u32> {
|
||||
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<JsValue> {
|
||||
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<JsValue> {
|
||||
@@ -950,6 +1335,8 @@ fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue>
|
||||
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<JsValue>)> = 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<Vec<FieldState>>, 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<Vec<Fi
|
||||
})
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
run_here(script, event, doc, fields, doc_scripts, limits)
|
||||
run_here(script, event, doc, fields, kept, skipped, limits)
|
||||
}
|
||||
|
||||
/// Host hooks that refuse to compile code from strings (`eval`, `Function` and its generator
|
||||
@@ -1256,7 +1662,22 @@ impl HostHooks for CheckedCodeFromStrings {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldState>>, 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<Vec<FieldState>>,
|
||||
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<Vec<FieldSta
|
||||
dropped: 0,
|
||||
}));
|
||||
let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() };
|
||||
let mut doc_errors: Vec<(usize, String)> = 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<Vec<FieldSta
|
||||
if let Err(e) = result {
|
||||
out.error = Some(e.to_string());
|
||||
}
|
||||
doc_errors.sort_by_key(|(i, _)| *i);
|
||||
out.doc_errors = doc_errors.into_iter().map(|(_, e)| e).collect();
|
||||
if let Some(h) = ctx.remove_data::<Shared>() {
|
||||
let h = h.into_inner();
|
||||
out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect();
|
||||
|
||||
+320
-2
@@ -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<String> = 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'); \
|
||||
|
||||
+25
-5
@@ -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<String>),
|
||||
/// `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::<String>())),
|
||||
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<JsValue> {
|
||||
|
||||
// ── 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<JsValue> {
|
||||
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<JsValue> {
|
||||
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<JsValue> {
|
||||
effect(ctx, XfaEffect::Print)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `xfa.host.beep()`: asks to beep.
|
||||
fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
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<JsValue> {
|
||||
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<JsValue> {
|
||||
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<JsVal
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `app.execMenuItem(name)`: the effect for the request the shared menu table gives the name
|
||||
/// (Save opens Save As too); an unknown name does nothing.
|
||||
fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
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<JsValue> {
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}")),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<field name="leave" w="2in" h="0.3in"><ui><dateTimeEdit/><picture>date{YYYYMMDD}|date{DDMMMYY}|date{YYMMDD}|date{YYYYMMD}|date{YY MM DD}</picture></ui><value><date/></value></field>
|
||||
<field name="born" w="2in" h="0.3in"><ui><dateTimeEdit/><picture>date{YYYY-MM-DD}</picture></ui><value><date/></value></field>
|
||||
<field name="name" w="2in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
<field name="code" w="2in" h="0.3in"><ui><dateTimeEdit/><picture>date{YYYYMMDD}|text{YYMMDD}</picture></ui><value><date/></value></field>
|
||||
<field name="short" w="2in" h="0.3in"><ui><dateTimeEdit/><picture>date{YYMMDD}|date{YYYYMMDD}</picture></ui><value><date/></value></field>
|
||||
<field name="pick" w="2in" h="0.3in"><ui><dateTimeEdit/><picture>date{YYMMDD}|date{DDMMYYYY}|date{YYYYMMDD}</picture></ui><value><date/></value></field>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##
|
||||
.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<u8> {
|
||||
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<u8> {
|
||||
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<u8>> = 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.
|
||||
|
||||
+279
-21
@@ -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<Rounded>),
|
||||
Text(Span),
|
||||
Image { rect: Rect, data: Arc<Vec<u8>>, content_type: String },
|
||||
Image {
|
||||
rect: Rect,
|
||||
data: Arc<Vec<u8>>,
|
||||
content_type: String,
|
||||
},
|
||||
Widget(Box<Widget>),
|
||||
}
|
||||
|
||||
/// 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<Border>,
|
||||
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<Frame>,
|
||||
}
|
||||
|
||||
/// 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<Form, XfaError>
|
||||
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<Item>, Option<Item>) {
|
||||
(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<FlowMark> {
|
||||
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<bool> = self.frames.iter().map(|f| self.on_area(&f.part)).collect();
|
||||
let filled: Vec<bool> = 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<Item> = 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<bool, XfaError> {
|
||||
/// 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<bool, XfaError> {
|
||||
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))];
|
||||
|
||||
@@ -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<Report, XfaError> {
|
||||
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 })
|
||||
}
|
||||
|
||||
+25
-2
@@ -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<Color>,
|
||||
}
|
||||
|
||||
@@ -144,6 +155,15 @@ impl Border {
|
||||
pub fn fill(&self) -> Option<Color> {
|
||||
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<Border>),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
@@ -636,6 +656,9 @@ pub struct Run {
|
||||
pub h_scale: Option<f64>,
|
||||
/// A floating field embedded in the text (`xfa:embed="#id"`), shown by its value.
|
||||
pub embed: Option<String>,
|
||||
/// Where the text links to (`<a href="…">`), as the template writes it: shared by every run
|
||||
/// of the link's text, not copied.
|
||||
pub link: Option<std::sync::Arc<str>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
|
||||
+30
-3
@@ -146,7 +146,23 @@ fn parse_border(n: Option<XmlNode>) -> Option<Border> {
|
||||
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 `<corner>` 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<Corner> = 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))
|
||||
}
|
||||
|
||||
/// `<font>`: 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 `<a href>` 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<bool>,
|
||||
size: Option<f64>,
|
||||
h_scale: Option<f64>,
|
||||
/// The `href` of the `<a>` the text is in.
|
||||
link: Option<std::sync::Arc<str>>,
|
||||
}
|
||||
/// `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 `<a>` 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<XmlNode>, warnings: &mut Vec<String>) -> 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)),
|
||||
_ => {}
|
||||
|
||||
+237
-21
@@ -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<String>,
|
||||
/// 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<ObjRef, XfaError> {
|
||||
let page_ref = self.doc.add(Object::Dict(Dict::new()));
|
||||
let mut content: Vec<u8> = 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<LinkPiece> = 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<LinkPiece>) -> Result<Option<ObjRef>, 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<Written, XfaError>
|
||||
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<Stripped, XfaError> {
|
||||
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<usize> {
|
||||
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<crate::Report> {
|
||||
let root = doc.root()?;
|
||||
|
||||
@@ -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<String> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -601,6 +601,9 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result<Report, XfaError>
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -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#"<field name="{name}" w="1in" h="0.3in"><ui><checkButton{shape}/></ui><items>{items}</items></field>"#)
|
||||
};
|
||||
let on_off = "<integer>1</integer><integer>0</integer>";
|
||||
let xdp = format!(
|
||||
r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
|
||||
{}{}<exclGroup name="pick" layout="tb">{}{}</exclGroup>
|
||||
</subform></template></xdp:xdp>"##,
|
||||
check("roundBox", r#" shape="round""#, on_off),
|
||||
check("squareBox", "", on_off),
|
||||
check("roundRadio", r#" shape="round""#, "<text>a</text>"),
|
||||
check("squareRadio", r#" shape="square""#, "<text>b</text>"),
|
||||
);
|
||||
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<String, bool> = [("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##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<draw name="note" w="{w}"><font typeface="Arial" size="10pt"/><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>{body}</p></body></exData></value></draw>
|
||||
</subform></template></xdp:xdp>"##
|
||||
)
|
||||
}
|
||||
|
||||
/// 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<f64> = 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 `<a href>` 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 <a href=" mailto:forms@example.com ">forms@<b>example</b>.com</a> or see <a name="top">the top</a>."#,
|
||||
))
|
||||
.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#"<a href="{href}">{words}<b>bold</b> {words}</a>"#));
|
||||
let form = layout_xml(&xdp).unwrap();
|
||||
let links: Vec<std::sync::Arc<str>> =
|
||||
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 <a href="https://example.com/forms/help">the help pages for this form, which explain every block</a> 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 <a href="mailto:forms@example.com">forms@<b>example</b>.com</a> 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#"<draw name="{name}" x="{x}" y="0in" w="0.4in" h="0.3in"><font typeface="Arial" size="10pt"/><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p style="margin:0{u}"><a href="https://example.com/form">Go</a></p></body></exData></value></draw>"#
|
||||
)
|
||||
};
|
||||
let rule = r#"<draw name="rule" x="0.21in" y="0in" w="0in" h="0.3in"><value><line><edge/></line></value></draw>"#;
|
||||
let level_rule = r#"<draw name="dash" x="0.2in" y="0.1in" w="0.04in" h="0in"><value><line><edge/></line></value></draw>"#;
|
||||
let form = |between: &str, underline: bool| {
|
||||
format!(
|
||||
r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="position">
|
||||
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
{}{between}{}
|
||||
</subform></template></xdp:xdp>"##,
|
||||
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#"<a href="javascript:app.alert(1)">one</a> <a href="file:///etc/passwd">two</a> <a href="data:text/html,x">three</a> <a href="help.html">four</a> <a href="HTTPS:">five</a> <a href="{long}">six</a> <a href="MailTo:a@example.com">seven</a> <a href="https:///path">eight</a> <a href="https://user@:8080/x">nine</a> <a href="https://\example.com/path">nine and a half</a> <a href="https://user@example.com:8443/x">ten</a>"#
|
||||
);
|
||||
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::<Vec<_>>(), ["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#"<draw name="{name}" x="0in" y="{y}in" w="2in" h="1in"><value><text>{name}</text></value>{border}</draw>"#)
|
||||
};
|
||||
let body = [
|
||||
draw("round", 0.0, r#"<border><edge thickness="1pt"><color value="255,0,0"/></edge><corner radius="0.25in"/><fill><color value="230,230,230"/></fill></border>"#),
|
||||
draw("mixed", 1.2, r#"<border><edge/><corner radius="0.1in"/><corner radius="0.2in" inverted="1"/></border>"#),
|
||||
draw("huge", 2.4, r#"<border><edge/><corner radius="3in"/></border>"#),
|
||||
draw("hiddenCorner", 3.6, r#"<border><edge/><corner radius="0.25in" presence="hidden"/></border>"#),
|
||||
draw("unlikeEdges", 4.8, r#"<border><edge/><edge presence="hidden"/><corner radius="0.25in"/></border>"#),
|
||||
draw("fillOnly", 6.0, r#"<border><edge presence="hidden"/><corner radius="0.25in"/><fill><color value="200,200,200"/></fill></border>"#),
|
||||
r#"<draw name="shape" x="0in" y="7.2in" w="2in" h="1in"><value><rectangle><edge thickness="0.5pt"/><corner radius="5.08mm"/></rectangle></value></draw>"#.to_string(),
|
||||
]
|
||||
.concat();
|
||||
let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"<subform name="body" layout="position" w="7.5in" h="9in">{body}</subform>"#)))
|
||||
.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#"<subform name="body" layout="tb" w="7.5in"><subform name="tall" layout="position" w="7.5in" h="12in"><border><edge/><corner radius="0.25in"/></border><draw name="note" x="0in" y="0in" w="2in" h="0.3in"><value><text>Note</text></value></draw></subform></subform>"#;
|
||||
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#"<field name="{name}" w="3in" h="0.5in"><ui><textEdit/></ui></field>"#);
|
||||
let boxed = |inner: &str| {
|
||||
format!(
|
||||
r#"<subform name="box" layout="tb" w="7.5in"><border><edge thickness="1pt"/><fill><color value="230,230,230"/></fill></border><margin topInset="0.1in" bottomInset="0.2in"/>{inner}</subform>"#
|
||||
)
|
||||
};
|
||||
let lay = |body: &str| {
|
||||
layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"<subform name="body" layout="tb" w="7.5in">{body}</subform>"#))).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#"<subform name="grid" layout="table" columnWidths="2in 2in" w="4in"><border><edge/></border><subform name="row" layout="row"><draw name="c1" h="0.4in"><value><text>One</text></value></draw><draw name="c2" h="0.4in"><value><text>Two</text></value></draw></subform></subform>"#;
|
||||
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<Rect> {
|
||||
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#"<field name="q{i}" w="3in" h="0.25in"><ui><textEdit/></ui></field>"#)).collect();
|
||||
let low = format!(r#"<subform name="box" layout="tb" w="7.5in"><border><edge/><fill/></border><margin bottomInset="0.4in"/>{quarter}</subform>"#);
|
||||
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#"<subform name="box" layout="tb" w="7.5in"><margin topInset="0.1in" bottomInset="0.2in"/>{many}</subform>"#);
|
||||
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#"<draw name="long" w="7in"><font typeface="Arial" size="10pt"/><value><text>{}</text></value></draw>"#,
|
||||
"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#"<field name="tall" w="3in" h="715pt"><ui><textEdit/></ui></field>"#;
|
||||
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#"<field name="spacer" w="3in" h="684pt"><ui><textEdit/></ui></field>"#;
|
||||
let form = lay(&format!("{spacer}{}", boxed(&(field("a") + &field("b")))));
|
||||
assert_eq!(form.pages.len(), 2);
|
||||
let fills = |page: usize| -> Vec<Rect> {
|
||||
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#"<field name="tall" w="3in" h="715pt"><ui><textEdit/></ui></field>"#));
|
||||
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#"<subform name="{name}" layout="row"><draw name="{name}a" h="0.3in"><value><text>{text}</text></value></draw><draw name="{name}b" h="0.3in"><value><text>-</text></value></draw></subform>"#
|
||||
)
|
||||
};
|
||||
let rows: String = (0..40).map(|i| row(&format!("r{i}"), &format!("Row {i}"))).collect();
|
||||
let table = format!(
|
||||
r#"<subform name="grid" layout="table" columnWidths="2in 2in" w="4in"><border><edge/><fill><color value="230,230,230"/></fill></border><margin topInset="0.1in" bottomInset="0.2in"/><overflow leader="head"/>{}{rows}</subform>"#,
|
||||
row("head", "Head")
|
||||
);
|
||||
let body = format!(r#"<subform name="body" layout="tb" w="7.5in">{table}</subform>"#);
|
||||
let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
|
||||
assert_eq!(form.pages.len(), 2);
|
||||
let fills = |page: usize| -> Vec<Rect> {
|
||||
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#"<subform name="{name}" layout="row"><draw name="{name}a" h="{h}"><value><text>{name}</text></value></draw></subform>"#)
|
||||
};
|
||||
let table = |rows: &str| {
|
||||
format!(
|
||||
r#"<subform name="body" layout="tb" w="7.5in"><subform name="grid" layout="table" columnWidths="3in" w="3in"><border><edge/><fill/></border><margin topInset="0.1in"/><overflow leader="head"/>{}{rows}</subform></subform>"#,
|
||||
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<Rect> {
|
||||
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#"<field name="{name}" w="3in" h="0.5in"><ui><textEdit/></ui></field>"#);
|
||||
let body = format!(
|
||||
r#"<subform name="body" layout="tb" w="7.5in"><subform name="box" layout="tb" w="7.5in"><border><edge/><fill/></border><margin topInset="0.1in" bottomInset="0.2in"/>{}<subform name="s1" layout="tb"><breakBefore targetType="pageArea"/>{}</subform><subform name="s2" layout="tb"><breakAfter targetType="pageArea"/>{}</subform>{}</subform></subform>"#,
|
||||
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<Rect> =
|
||||
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("<form><page1><country>JP</country></page1></form>");
|
||||
// What `/Perms` holds once the layout is saved and the file read again.
|
||||
let laid_out = |bytes: Vec<u8>| {
|
||||
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);
|
||||
}
|
||||
|
||||
+36
-27
@@ -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 `<a href>`), as the template writes it, shared with the
|
||||
/// link's other spans.
|
||||
pub link: Option<std::sync::Arc<str>>,
|
||||
}
|
||||
|
||||
/// 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<std::sync::Arc<str>>,
|
||||
}
|
||||
|
||||
/// 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<Span> = 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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.<br>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).<br>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.<br>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<br>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.<br>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<br>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<br>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.<br>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<br>Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).<br>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<br>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. |
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user