diff --git a/Cargo.lock b/Cargo.lock index 7458e0af6f..9cf008f02e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,7 +238,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -321,7 +321,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "slab", "windows-sys 0.61.2", ] @@ -352,7 +352,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -378,7 +378,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.5", + "rustix 1.1.6", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -878,7 +878,7 @@ checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags 2.13.2", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "slab", "tracing", ] @@ -902,7 +902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.4", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-client", ] @@ -2050,7 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2427,7 +2427,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "windows-link", ] @@ -4172,7 +4172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4338,7 +4338,7 @@ dependencies = [ "pdfcraft-render", "pdfcraft-xfa", "png 0.18.1", - "rustix 1.1.5", + "rustix 1.1.6", "serde_json", "sha2 0.11.1", "tempfile", @@ -4357,7 +4357,7 @@ dependencies = [ "pdfcraft-preflight", "pdfcraft-render", "png 0.18.1", - "rustix 1.1.5", + "rustix 1.1.6", "serde_json", "winresource", ] @@ -4405,6 +4405,7 @@ dependencies = [ "pdfcraft-model", "pdfcraft-xfa", "png 0.18.1", + "rustix 1.1.6", "thiserror 2.0.21", "tiff", "zune-jpeg", @@ -4625,7 +4626,7 @@ dependencies = [ "async-channel", "futures-lite", "log", - "rustix 1.1.5", + "rustix 1.1.6", "zbus", ] @@ -4807,7 +4808,7 @@ dependencies = [ "pollster 1.0.1", "proptest", "rfd", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_json", "skrifa 0.44.0", @@ -5143,7 +5144,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.5", + "rustix 1.1.6", "windows-sys 0.61.2", ] @@ -5401,7 +5402,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5953,15 +5954,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6031,7 +6032,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6655,7 +6656,7 @@ dependencies = [ "libc", "log", "memmap2", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "wayland-backend", "wayland-client", @@ -6920,8 +6921,8 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.5", - "windows-sys 0.61.2", + "rustix 1.1.6", + "windows-sys 0.52.0", ] [[package]] @@ -7673,7 +7674,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.5", + "rustix 1.1.6", "scoped-tls", "smallvec", "wayland-sys", @@ -7686,7 +7687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.2", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-scanner", ] @@ -7708,7 +7709,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "wayland-client", "xcursor", ] @@ -8046,7 +8047,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8404,7 +8405,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "tree_magic_mini", "wayland-backend", @@ -8462,7 +8463,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.5", + "rustix 1.1.6", "x11rb-protocol", ] @@ -8504,7 +8505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -8611,7 +8612,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_repr", "tracing", diff --git a/ROADMAP.md b/ROADMAP.md index 7838ac2ff4..86ee190fa6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,6 +87,7 @@ > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M10, gap 8: Office documents open through an installed LibreOffice; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -494,6 +495,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-16 (M14, localization):** Czech goes from menus-and-dialogs to the whole surface: 2,674 entries after adding the ~220 keys the other catalogs had -- the sentences the engine words (signature details, accessibility findings and report, PDF/A problems, pages to print, history labels), the date picker's weekday heads, the certificate viewer's fields and shared buttons. "Tag" is *tag* throughout (the Tags panel said *značky*, which is also a redaction mark), and "{n} tags" no longer reads "1 značek". Two new catalog tests. Estimates unchanged. - **2026-10-16 (M5, #2583, #1869):** A sticky note on a page with `/Rotate` is drawn upright, with its top-left where it was placed. Notes are saved with the NoRotate flag, and the renderer now honours it: the appearance is turned against the page's rotation about the upper-left corner of `/Rect`, which stays put (ISO 32000-2 §12.5.3; vendored hayro-interpret patch 37). Attachment icons follow the same rule. Selection, hover, the edit composer, the Comments panel's flash and the `comment_list` and `doc_info` rectangles use the drawn box, and `comment_add` anchors an icon's displayed top-left at `at`. Estimates unchanged. - **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged. +- **2026-10-17 (M10, gap 8):** File ▸ Open opens Word, Excel, PowerPoint, OpenDocument and RTF files as new, unsaved PDFs, converted on the open worker by an installed LibreOffice (headless, a private temporary folder and fresh profile with macros off, stopped after 120 s); without LibreOffice the user is told it is needed. The Open dialog lists PDF (the default), Images, Office documents, Text and All files. Insert, Combine and Create ▸ Multiple files refuse Office documents for now, since they convert on the frame thread. `create.from-office` planned → partial. On 2026-10-26 the profile also stopped Writer and Calc from updating linked content (Impress has no such setting), and tests that skip without LibreOffice convert generated DOCX, XLSX, PPTX and RTF files through `doc_create_multiple` and `doc_combine` and check their text. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged. diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 30d92f884b..a849ec3e64 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -21,12 +21,35 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { } } +/// The optional `field_id` argument: a field's identity from form_fields, selecting one of +/// fields sharing a name. +fn field_id(a: &Args) -> Result> { + a.0.get("field_id") + .map(|v| { + v.as_u64() + .filter(|id| *id < 1 << 48) + .ok_or_else(|| ToolError::InvalidArgs("field_id must be an integer between 0 and 281474976710655".into())) + }) + .transpose() +} + /// A message a script showed, as `js_run` reports it: its text, title (null when it gave none), /// icon and buttons. pub(crate) fn alert_json(m: &pdfcraft_engine::js::Message) -> Value { json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() }) } +/// What scripts that ran unasked (a document's Will Save or Will Print actions) showed, said +/// or asked for, as tools report it. +pub(crate) fn js_output_json(out: &pdfcraft_engine::js::JsOutput) -> Value { + json!({ + "alerts": out.alerts.iter().map(alert_json).collect::>(), + "console": out.console, + "requests": out.requests.iter().map(request_json).collect::>(), + "errors": out.errors, + }) +} + impl Automation { /// `js_run`: run `script` in the document, or as button `field`'s script, and report its /// messages, console lines, requests, error, the failing document scripts' errors and result. @@ -44,14 +67,7 @@ impl Automation { .collect::>>()?, _ => return Err(ToolError::InvalidArgs("answers must be an array of at most 32 strings or null".into())), }; - let identity = - a.0.get("field_id") - .map(|v| { - v.as_u64() - .filter(|id| *id < 1 << 48) - .ok_or_else(|| ToolError::InvalidArgs("field_id must be an integer between 0 and 281474976710655".into())) - }) - .transpose()?; + let identity = field_id(a)?; let (script, field) = (a.str("script")?, a.opt_str("field")?); let (mut o, edit) = self.session.with_own_js_output(id, |s| s.run_javascript_answers_target(id, script, field, identity, &answers)).map_err(failed)?; @@ -175,6 +191,39 @@ impl Automation { ) } + /// `js_event`: run the JavaScript widget `widget` (1-based) of field `field` has for `event` + /// (a trigger id: mouse_enter, mouse_exit, mouse_down, mouse_up, on_focus, on_blur); returns + /// whether the form changed and what the script showed or asked for. + pub(crate) fn js_event(&mut self, a: &Args) -> Result { + let event = a.str("event")?; + let trigger = pdfcraft_engine::FieldTrigger::from_id(event).ok_or_else(|| { + ToolError::InvalidArgs(format!("event must be mouse_enter, mouse_exit, mouse_down, mouse_up, on_focus or on_blur, not {event:?}")) + })?; + let name = a.str("field")?; + let identity = field_id(a)?; + let doc = self.doc(a)?; + let field = + doc.form.iter().find(|f| f.name == name && identity.is_none_or(|i| pdfcraft_engine::js::field_object_id(f.obj) == i)).ok_or_else( + || match identity { + Some(i) => failed(format!("there is no field named {name:?} with field_id {i} (see form_fields)")), + None => failed(format!("there is no field named {name:?} (see form_fields)")), + }, + )?; + let widgets = field.widgets.len(); + let widget = a.opt_int("widget")?.unwrap_or(1); + let widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets { + 0 => ToolError::InvalidArgs(format!("{name:?} has no widget")), + n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")), + })?; + let (id, object) = (doc.id, pdfcraft_engine::js::field_object_id(field.obj)); + // Only this event's output: what opening or an earlier tool left waiting stays. + let (changed, out) = + self.session.with_own_js_output(id, |s| s.field_trigger_target(id, name, Some(object), widget, trigger)).map_err(failed)?; + let mut result = js_output_json(&out); + result["changed"] = json!(changed); + Ok(result) + } + /// `form_merge_data`: merge the form data files at `paths` into one CSV at `path`, a row per /// file; returns the path and the row and column counts. pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 8947d6a034..617b3d0f8b 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -835,6 +835,7 @@ impl Automation { "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, "ocr_recognize" => self.ocr_recognize(&a)?, "js_run" => self.js_run(&a)?, + "js_event" => self.js_event(&a)?, "js_page" => self.js_page(&a)?, "js_advance" => self.js_advance(&a)?, "js_document_scripts" => self.js_document_scripts(&a)?, diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index c00034521c..7dd78cff98 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -168,11 +168,19 @@ impl Automation { if flatten_fill_sign { self.apply(a, Edit::FlattenFillSign)?; } + // The document's Will Save actions run first: what they change is saved (#269). + let scripts = self.session.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillSave); let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?; // A save that adds or removes encryption, or must not keep the old revision (Sanitize, // redaction), is rewritten in full even when an incremental save was asked for. let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); - self.publish_save(id, &target, bytes, incremental, write) + let mut out = self.publish_save(id, &target, bytes, incremental, write)?; + if !scripts.is_empty() + && let Some(o) = out.as_object_mut() + { + o.insert("will_save".into(), crate::a11y::form_tools::js_output_json(&scripts)); + } + Ok(out) } /// Validate the prepared bytes before publishing them or marking the document saved. diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 5d03304db1..3d7a76865a 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -361,29 +361,50 @@ impl Automation { let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new(), area }; let laid_out = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?; let sheets = laid_out.len(); + // Where it goes is checked before the Will Print actions run, so a call that can't print + // doesn't change the document. + let destination = match (a.opt_str("path")?, printer) { + (Some(p), None) => Destination::File(self.resolve(p, true)?), + (None, Some(printer)) => { + let actual_printer = resolved_printer.as_deref().unwrap_or(printer); + Destination::Printer(spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?) + } + _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), + }; + // The document's Will Print actions run first: what they change is printed (#269). + let scripts = if self.doc(a)?.allows_printing() { + self.session.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillPrint) + } else { + Default::default() + }; let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() }); - match (a.opt_str("path")?, printer) { - (Some(p), None) => { - let path = self.resolve(p, true)?; + if !scripts.is_empty() { + out["will_print"] = crate::a11y::form_tools::js_output_json(&scripts); + } + match destination { + Destination::File(path) => { write_atomic(&path, &bytes)?; out["path"] = json!(path.to_string_lossy()); } - (None, Some(printer)) => { - let actual_printer = resolved_printer.as_deref().unwrap_or(printer); - let mut job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?; + Destination::Printer(mut job) => { // A GDI (v3) driver gets transparent sheets flattened (#6155, #5838, #5940); XPS // (v4) drivers print them as they are. let flatten = job.printer.as_deref().is_some_and(spool::gdi_driver); let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job, flatten); out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } - _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), } Ok(out) } } +/// Where `doc_print` sends the sheets: a print-ready PDF file, or a printer's queue. +enum Destination { + File(std::path::PathBuf), + Printer(spool::Job), +} + /// "WxH" with a unit (in, mm, cm or pt) as a portrait paper size in points; each side 1 to 200 /// inches (PDF's own page limit). fn paper_size(spec: &str) -> Option<(f64, f64)> { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 152e10398b..2a7fceb6f0 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -347,7 +347,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }), &["doc", "pages"], )), - t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.") + t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000), .txt files and Office documents (Word, Excel, PowerPoint, OpenDocument, RTF; through an installed LibreOffice) are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.") .cmd("page.combine") .with(schema( json!({ @@ -361,7 +361,7 @@ pub fn tools() -> Vec { }), &["paths"], )), - t("doc_create_multiple", "Create PDF from multiple files", "Convert several files (PDFs, PNG/JPEG/JPEG 2000/TIFF/GIF/BMP images, .txt files) to PDF in one run. mode \"combine\" (default) joins them, in order, into one PDF with a bookmark per file; pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null). mode \"separate\" writes one PDF per file into out_dir (files that are already PDFs are skipped; existing files are never overwritten) and reports each file's result.") + t("doc_create_multiple", "Create PDF from multiple files", "Convert several files (PDFs, PNG/JPEG/JPEG 2000/TIFF/GIF/BMP images, .txt files, Office documents through an installed LibreOffice) to PDF in one run. mode \"combine\" (default) joins them, in order, into one PDF with a bookmark per file; pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null). mode \"separate\" writes one PDF per file into out_dir (files that are already PDFs are skipped; existing files are never overwritten) and reports each file's result.") .cmd("create.multiple") .with(schema( json!({ @@ -543,7 +543,7 @@ pub fn tools() -> Vec { t( "form_focus", "Move the focus into or out of a field", - "A field gains (event enter) or loses (event exit) focus, as when someone clicks or tabs into or out of it, and the dynamic XFA form's enter or exit scripts for it run (other forms have none, and nothing happens). widget picks the radio button or copy of the field (1-based, default 1; see form_fields). What the scripts change is one undoable edit, and none when they change nothing. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. Returns changed, and the scripts' alerts, console output, requests and errors.", + "A field gains (event enter) or loses (event exit) focus, as when someone clicks or tabs into or out of it, and the dynamic XFA form's enter or exit scripts for it run; on another form, the widget's own On Focus or On Blur JavaScript runs, if it has one (see also js_event). widget picks the radio button or copy of the field (1-based, default 1; see form_fields). What the scripts change is one undoable edit, and none when they change nothing. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. Returns changed, and the scripts' alerts, console output, requests and errors.", ) .with(schema( json!({ @@ -1421,6 +1421,21 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." }, "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact field identity from form_fields; requires matching field name and is scoped to this document."}, "answers": {"type":"array","maxItems":32,"items":{"type":["string","null"],"maxLength":4096},"description":"Ordered popup, response and alert answers; strings are limited to 4096 UTF-8 bytes at runtime; null cancels a popup or response, and selects an alert default (OK, Cancel or No, by button set)."} }), &["doc", "script"], )), + t( + "js_event", + "Run a field's pointer or focus script", + "Run the JavaScript action widget `widget` of AcroForm field `field` has for `event`, as when the pointer enters (mouse_enter), leaves (mouse_exit), presses (mouse_down) or releases (mouse_up) it, or it gains (on_focus) or loses (on_blur) the focus, in the document's script realm (variables document scripts and earlier scripts left are kept), with event.name Mouse Enter, Mouse Exit, Mouse Down, Mouse Up, Focus or Blur. Nothing runs when the widget has no JavaScript for that event, JavaScript is off, or the form is a laid-out XFA form (use form_focus and js_run there). What it changes is one undoable edit. Pointer-over and focus scripts run unasked in a viewer, so their requests to print, open Save As or show a panel are dropped (noted on the console); mouse_down and mouse_up keep them. Returns changed, and the script's alerts, console output, requests and errors.", + ) + .with(schema( + json!({ + "doc": doc(), + "field": { "type": "string", "description": "Field name (from form_fields)." }, + "event": { "type": "string", "enum": ["mouse_enter", "mouse_exit", "mouse_down", "mouse_up", "on_focus", "on_blur"] }, + "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact field identity from form_fields: of fields sharing the name, the one to run."}, + "widget": { "type": "integer", "minimum": 1, "description": "Widget of the field (1-based, default 1): a radio button of the group, or a copy of the field." }, + }), + &["doc", "field", "event"], + )), t("js_page", "Run page actions", "Read the document at `page`, as the viewer does when that page becomes the one being read: the actions the page it was at runs as it closes, then this page's open actions (its own, and its annotations' page-open and page-visible actions, which start an animation made with the LaTeX animate package), in the document's JavaScript context, which buttons, the console and timers share. Fields the actions show or hide change only how the document is shown, and page_render draws them so; values they change are applied as one undoable step. Returns the actions' alerts, console output, requests and errors, the pages showing a field they showed or hid, and when the next timer falls due on the script clock (js_advance moves it).") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), t("js_advance", "Advance the script clock", "Move the document's script clock on by `ms` milliseconds and run the timers its scripts set (app.setInterval, app.setTimeOut) as they fall due on the way, in order, as the viewer runs them over time: an animation shows its next frames. Fields timers show or hide change only how the document is shown, and page_render draws them so. A timer's alerts and requests go to its console output instead. Returns how many timers ran, their console output and errors, the pages showing a field they showed or hid, the clock, and when the next timer falls due.") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 3a87607373..54e10a035e 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -1226,7 +1226,7 @@ fn combine_takes_images_and_text_files() { std::fs::write(dir.join("scan.png"), png).unwrap(); std::fs::write(dir.join("notes.txt"), "Some notes").unwrap(); std::fs::write(dir.join("broken.png"), b"\x89PNG\r\n\x1a\nbroken").unwrap(); - std::fs::write(dir.join("report.docx"), b"PK\x03\x04").unwrap(); + std::fs::write(dir.join("archive.zip"), b"PK\x03\x04").unwrap(); let mut a = auto(&dir); let combined = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "scan.png", "notes.txt"], "pages": [null, null, "1"], "open": true })); let doc = combined["document"]["doc"].as_u64().unwrap(); @@ -1236,7 +1236,7 @@ fn combine_takes_images_and_text_files() { let titles: Vec<&str> = marks.as_array().unwrap().iter().map(|b| b["title"].as_str().unwrap()).collect(); assert_eq!(titles, ["a", "scan", "notes"]); // A file that can't be made into a PDF stops the combine, and says which. - for bad in ["broken.png", "report.docx"] { + for bad in ["broken.png", "archive.zip"] { let refused = a.call("doc_combine", &json!({ "paths": ["a.pdf", bad] })); assert!(matches!(&refused, Err(ToolError::Failed(e)) if e.contains(bad)), "{bad}: {refused:?}"); } @@ -5452,7 +5452,7 @@ fn creating_from_multiple_files_through_tools() { } std::fs::write(dir.join("scan.png"), png).unwrap(); std::fs::write(dir.join("notes.txt"), "hello").unwrap(); - std::fs::write(dir.join("report.docx"), b"PK\x03\x04").unwrap(); + std::fs::write(dir.join("archive.zip"), b"PK\x03\x04").unwrap(); let mut a = auto(&dir); // Combine: every file converted, in the order given, with a bookmark per file. @@ -5472,8 +5472,8 @@ fn creating_from_multiple_files_through_tools() { assert_eq!(titles, ["notes", "a", "scan"]); assert!(dir.join("all.pdf").is_file()); // A file that can't be converted fails the whole combine, naming it. - let err = a.call("doc_create_multiple", &json!({ "paths": ["a.pdf", "report.docx"] })).unwrap_err(); - assert!(matches!(&err, ToolError::Failed(m) if m.contains("report.docx") && m.contains("can't be converted")), "{err}"); + let err = a.call("doc_create_multiple", &json!({ "paths": ["a.pdf", "archive.zip"] })).unwrap_err(); + assert!(matches!(&err, ToolError::Failed(m) if m.contains("archive.zip") && m.contains("can't be converted")), "{err}"); assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": [] })), Err(ToolError::InvalidArgs(_)))); assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": ["a.pdf"], "mode": "zip" })), Err(ToolError::InvalidArgs(_)))); assert!(matches!(a.call("doc_create_multiple", &json!({ "paths": ["a.pdf"], "mode": "separate" })), Err(ToolError::InvalidArgs(_)))); @@ -5482,7 +5482,7 @@ fn creating_from_multiple_files_through_tools() { // existing file is never overwritten. std::fs::create_dir_all(dir.join("out")).unwrap(); std::fs::write(dir.join("out/notes.pdf"), b"mine").unwrap(); - let args = json!({ "paths": ["notes.txt", "report.docx", "a.pdf", "scan.png", "missing.txt"], "mode": "separate", "out_dir": "out" }); + let args = json!({ "paths": ["notes.txt", "archive.zip", "a.pdf", "scan.png", "missing.txt"], "mode": "separate", "out_dir": "out" }); let made = ok(&mut a, "doc_create_multiple", args); let files = made["files"].as_array().unwrap(); assert!(files[0]["output"].as_str().unwrap().ends_with("notes (2).pdf"), "{files:?}"); diff --git a/crates/automation/tests/it/document_actions.rs b/crates/automation/tests/it/document_actions.rs new file mode 100644 index 0000000000..a8646a4437 --- /dev/null +++ b/crates/automation/tests/it/document_actions.rs @@ -0,0 +1,125 @@ +//! A form's document actions and pointer/focus scripts through the tool table (#269, ported +//! from #344): the open action runs on doc_open, Will Save on doc_save, Will Print on +//! doc_print, and js_event runs a widget's pointer and focus scripts, all in one realm. + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +fn pdf_of(objs: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// `log` records what ran; `hot` has Mouse Enter and On Blur scripts. The open action counts +/// in a variable the Will Save and Will Print actions and `hot`'s scripts read. +fn form() -> Vec { + pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /OpenAction 6 0 R /AA << /WS 7 0 R /WP 8 0 R >> /AcroForm << /Fields [4 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 9 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /S /JavaScript /JS (var n = 1; getField\\('log'\\).value = 'open' + n;) >>".into(), + "<< /S /JavaScript /JS (n++; getField\\('log'\\).value += ' save' + n; console.println\\('saving'\\);) >>".into(), + "<< /S /JavaScript /JS (n++; getField\\('log'\\).value += ' print' + n;) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E << /S /JavaScript /JS (n++; getField\\('log'\\).value += ' ' + event.name + n;) >> /Bl << /S /JavaScript /JS (this.print\\(\\); getField\\('log'\\).value += ' ' + event.name;) >> >> >>".into(), + ]) +} + +fn log(a: &mut Automation, doc: u64) -> Value { + let f = tool(a, "form_fields", json!({ "doc": doc })); + f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "log").map(|f| f["value"].clone()).unwrap() +} + +#[test] +fn open_will_save_will_print_and_field_events_share_the_document_realm() { + let dir = std::env::temp_dir().join(format!("pdfcraft-document-actions-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("form.pdf"), form()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000); + let doc = tool(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(log(&mut a, doc), "open1"); + + let entered = tool(&mut a, "js_event", json!({ "doc": doc, "field": "hot", "event": "mouse_enter" })); + assert_eq!(entered["changed"], true, "{entered}"); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2"); + // Losing the focus happens unasked: its print request is dropped and noted. + let blurred = tool(&mut a, "js_event", json!({ "doc": doc, "field": "hot", "event": "on_blur" })); + assert_eq!(blurred["requests"], json!([]), "{blurred}"); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur"); + let none = tool(&mut a, "js_event", json!({ "doc": doc, "field": "log", "event": "mouse_down" })); + assert_eq!(none["changed"], false, "no script: {none}"); + assert!(a.call("js_event", &json!({ "doc": doc, "field": "hot", "event": "keystroke" })).is_err()); + assert!(a.call("js_event", &json!({ "doc": doc, "field": "hot", "event": "mouse_up", "widget": 2 })).is_err()); + + // Saved in place (incrementally): object numbers stay, so the realm is kept. A full + // rewrite starts a fresh realm for the next event. + let saved = tool(&mut a, "doc_save", json!({ "doc": doc })); + assert_eq!(saved["incremental"], true, "{saved}"); + std::fs::copy(dir.join("form.pdf"), dir.join("saved.pdf")).unwrap(); + assert_eq!(saved["will_save"]["console"], json!(["saving"]), "{saved}"); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3"); + + let printed = tool(&mut a, "doc_print", json!({ "doc": doc, "path": "printed.pdf" })); + assert!(printed.get("will_print").is_none(), "nothing to report: {printed}"); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3 print4"); + // A print with nowhere to go fails before Will Print runs. + assert!(a.call("doc_print", &json!({ "doc": doc })).is_err()); + assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3 print4"); + + // What Will Save changed is in the saved file. + tool(&mut a, "js_enabled", json!({ "enabled": false })); + let reopened = tool(&mut a, "doc_open", json!({ "path": "saved.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(log(&mut a, reopened), "open1 Mouse Enter2 Blur save3"); +} + +/// Of two fields sharing a name, `field_id` (from form_fields) picks the one js_event runs; by +/// name alone it is the first. +#[test] +fn js_event_runs_the_field_its_field_id_names() { + let enter = |text: &str| format!("/AA << /E << /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) >> >>"); + let pdf = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 {} >>", enter("first;")), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 150 180 170] /P 3 0 R /F 4 {} >>", enter("second;")), + ]); + let dir = std::env::temp_dir().join(format!("pdfcraft-js-event-dup-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("dup.pdf"), pdf).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = tool(&mut a, "doc_open", json!({ "path": "dup.pdf" }))["doc"].as_u64().unwrap(); + let fields = tool(&mut a, "form_fields", json!({ "doc": doc })); + let ids: Vec = fields["fields"].as_array().unwrap().iter().filter(|f| f["name"] == "dup").map(|f| f["field_id"].as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 2, "{fields}"); + tool(&mut a, "js_event", json!({ "doc": doc, "field": "dup", "field_id": ids[1], "event": "mouse_enter" })); + assert_eq!(log(&mut a, doc), "second;"); + tool(&mut a, "js_event", json!({ "doc": doc, "field": "dup", "event": "mouse_enter" })); + assert_eq!(log(&mut a, doc), "second;first;"); + assert!( + a.call("js_event", &json!({ "doc": doc, "field": "log", "field_id": ids[1], "event": "mouse_enter" })).is_err(), + "the id is another field's" + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index be655a10a2..3231364e37 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -9,12 +9,14 @@ mod batch; mod batch_failures; mod batch_secrets; mod button_caption; +mod document_actions; mod find_text; mod form_bulk; mod hostile_args; mod list_selection; mod model_3d; mod model_render; +mod office; mod optimize_content_boundaries; mod page_actions; mod portfolio_info; diff --git a/crates/automation/tests/it/office.rs b/crates/automation/tests/it/office.rs new file mode 100644 index 0000000000..e406b19ad8 --- /dev/null +++ b/crates/automation/tests/it/office.rs @@ -0,0 +1,277 @@ +//! Office documents converted by a real LibreOffice through the tools that take them +//! (`doc_create_multiple`, `doc_combine`), with the converted text checked. Every test here +//! skips when LibreOffice isn't installed, which is the case on CI; run them on a machine that +//! has it. The Word, Excel and PowerPoint files are written here, as the smallest packages +//! LibreOffice reads, so no binary fixtures are committed. + +use std::path::Path; + +use pdfcraft_automation::{Automation, Content, ToolError}; +use serde_json::{Value, json}; + +fn auto(dir: &Path) -> Automation { + Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000) +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +/// Each page's text, with runs of whitespace made single spaces. +fn page_text(a: &mut Automation, doc: u64) -> Vec { + ok(a, "text_extract", json!({ "doc": doc }))["pages"] + .as_array() + .unwrap() + .iter() + .map(|p| p["text"].as_str().unwrap().split_whitespace().collect::>().join(" ")) + .collect() +} + +/// A ZIP of `entries`, stored without compression (Office packages may be). +fn zip(entries: &[(&str, &str)]) -> Vec { + fn crc32(data: &[u8]) -> u32 { + let mut crc = !0u32; + for &b in data { + crc ^= u32::from(b); + for _ in 0..8 { + crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + } + } + !crc + } + let (mut out, mut central) = (Vec::new(), Vec::new()); + for (name, data) in entries { + let (crc, size, offset) = (crc32(data.as_bytes()), data.len() as u32, out.len() as u32); + // version 2.0, no flags, stored, 1980-01-01 00:00 + let common = [&20u16.to_le_bytes()[..], &0u16.to_le_bytes(), &0u16.to_le_bytes(), &0u16.to_le_bytes(), &0x21u16.to_le_bytes()].concat(); + let sizes = [crc.to_le_bytes(), size.to_le_bytes(), size.to_le_bytes()].concat(); + out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); + out.extend_from_slice(&common); + out.extend_from_slice(&sizes); + out.extend_from_slice(&(name.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data.as_bytes()); + central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); + central.extend_from_slice(&20u16.to_le_bytes()); + central.extend_from_slice(&common); + central.extend_from_slice(&sizes); + central.extend_from_slice(&(name.len() as u16).to_le_bytes()); + // extra, comment, disk, internal and external attributes + central.extend_from_slice(&[0u8; 12]); + central.extend_from_slice(&offset.to_le_bytes()); + central.extend_from_slice(name.as_bytes()); + } + let (at, count) = (out.len() as u32, entries.len() as u16); + out.extend_from_slice(¢ral); + out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); + out.extend_from_slice(&[0u8; 4]); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&(central.len() as u32).to_le_bytes()); + out.extend_from_slice(&at.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out +} + +const XML: &str = r#""#; +const PACKAGE_RELS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; +const DOC_RELS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +/// `[Content_Types].xml` with one override per (part, type). +fn content_types(parts: &[(&str, &str)]) -> String { + let overrides: String = parts.iter().map(|(part, kind)| format!(r#""#)).collect(); + format!( + r#"{XML}{overrides}"# + ) +} + +/// A relationships part with one relationship per (id, type under officeDocument, target). +fn rels(list: &[(&str, &str, &str)]) -> String { + let items: String = + list.iter().map(|(id, kind, target)| format!(r#""#)).collect(); + format!(r#"{XML}{items}"#) +} + +/// A Word document of one paragraph. +fn docx(text: &str) -> Vec { + let document = format!( + r#"{XML}{text}"# + ); + zip(&[ + ( + "[Content_Types].xml", + &content_types(&[("word/document.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml")]), + ), + ("_rels/.rels", &rels(&[("rId1", "officeDocument", "word/document.xml")])), + ("word/document.xml", &document), + ]) +} + +/// An Excel workbook whose one sheet holds `text` in A1 and a formula, `=6*7`, in A2. +fn xlsx(text: &str) -> Vec { + let workbook = format!( + r#"{XML}"# + ); + let sheet = format!( + r#"{XML}{text}6*7"# + ); + zip(&[ + ( + "[Content_Types].xml", + &content_types(&[ + ("xl/workbook.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"), + ("xl/worksheets/sheet1.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"), + ]), + ), + ("_rels/.rels", &rels(&[("rId1", "officeDocument", "xl/workbook.xml")])), + ("xl/workbook.xml", &workbook), + ("xl/_rels/workbook.xml.rels", &rels(&[("rId1", "worksheet", "worksheets/sheet1.xml")])), + ("xl/worksheets/sheet1.xml", &sheet), + ]) +} + +/// A PowerPoint presentation of one slide with one text box. +fn pptx(text: &str) -> Vec { + const P: &str = + r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main""#; + let presentation = format!( + r#"{XML}"# + ); + let tree = r#""#; + let master = format!( + r#"{XML}{tree}"# + ); + let layout = format!(r#"{XML}{tree}"#); + let slide = format!( + r#"{XML}{tree}{text}"# + ); + let theme = format!( + r#"{XML}"# + ); + const PML: &str = "application/vnd.openxmlformats-officedocument.presentationml"; + let types = content_types(&[ + ("ppt/presentation.xml", &format!("{PML}.presentation.main+xml")), + ("ppt/slideMasters/slideMaster1.xml", &format!("{PML}.slideMaster+xml")), + ("ppt/slideLayouts/slideLayout1.xml", &format!("{PML}.slideLayout+xml")), + ("ppt/slides/slide1.xml", &format!("{PML}.slide+xml")), + ("ppt/theme/theme1.xml", "application/vnd.openxmlformats-officedocument.theme+xml"), + ]); + zip(&[ + ("[Content_Types].xml", &types), + ("_rels/.rels", &rels(&[("rId1", "officeDocument", "ppt/presentation.xml")])), + ("ppt/presentation.xml", &presentation), + ( + "ppt/_rels/presentation.xml.rels", + &rels(&[ + ("rId1", "slideMaster", "slideMasters/slideMaster1.xml"), + ("rId2", "slide", "slides/slide1.xml"), + ("rId3", "theme", "theme/theme1.xml"), + ]), + ), + ("ppt/slideMasters/slideMaster1.xml", &master), + ( + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + &rels(&[("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml"), ("rId2", "theme", "../theme/theme1.xml")]), + ), + ("ppt/slideLayouts/slideLayout1.xml", &layout), + ("ppt/slideLayouts/_rels/slideLayout1.xml.rels", &rels(&[("rId1", "slideMaster", "../slideMasters/slideMaster1.xml")])), + ("ppt/slides/slide1.xml", &slide), + ("ppt/slides/_rels/slide1.xml.rels", &rels(&[("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml")])), + ("ppt/theme/theme1.xml", &theme), + ]) +} + +/// An RTF document of one paragraph. +fn rtf(text: &str) -> Vec { + format!("{{\\rtf1\\ansi{{\\fonttbl\\f0\\fswiss Helvetica;}}\\f0\\pard {text}\\par}}").into_bytes() +} + +/// A folder with one Word, Excel, PowerPoint and RTF file, each carrying its own word, and the +/// tools rooted there; `None` when LibreOffice isn't installed. +fn office_files() -> Option<(tempfile::TempDir, Automation)> { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("letter.docx"), docx("Quokka letter")).unwrap(); + std::fs::write(dir.path().join("budget.xlsx"), xlsx("Narwhal budget")).unwrap(); + std::fs::write(dir.path().join("deck.pptx"), pptx("Axolotl deck")).unwrap(); + std::fs::write(dir.path().join("notes.rtf"), rtf("Pangolin notes")).unwrap(); + let mut a = auto(dir.path()); + match a.call("doc_create_multiple", &json!({ "paths": ["notes.rtf"] })) { + Err(ToolError::Failed(e)) if e.contains("needs LibreOffice") => { + eprintln!("skipped: LibreOffice isn't installed"); + None + } + _ => Some((dir, a)), + } +} + +/// Each Office format converts to a PDF of its own that holds the document's text (and, for +/// the spreadsheet, the formula's value). +#[test] +fn office_documents_convert_separately_with_their_text() { + let Some((dir, mut a)) = office_files() else { return }; + let paths = ["letter.docx", "budget.xlsx", "deck.pptx", "notes.rtf"]; + let made = ok(&mut a, "doc_create_multiple", json!({ "paths": paths, "mode": "separate", "out_dir": "out" })); + let files = made["files"].as_array().unwrap(); + assert_eq!(files.len(), 4, "{made}"); + let expected = [ + ("letter.pdf", &["Quokka letter"][..]), + ("budget.pdf", &["Narwhal budget", "42"]), + ("deck.pdf", &["Axolotl deck"]), + ("notes.pdf", &["Pangolin notes"]), + ]; + for (file, (name, words)) in files.iter().zip(expected) { + let output = file["output"].as_str().unwrap_or_else(|| panic!("{file}")); + // The tool reports the folder in its canonical form (`\\?\C:\…` on Windows). + assert_eq!(Path::new(output).file_name(), Some(std::ffi::OsStr::new(name)), "{output}"); + assert!(dir.path().join("out").join(name).is_file(), "{name}"); + let doc = ok(&mut a, "doc_open", json!({ "path": output }))["doc"].as_u64().unwrap(); + let text = page_text(&mut a, doc).join("\n"); + for word in words { + assert!(text.contains(word), "{output}: {word:?} not in {text:?}"); + } + } +} + +/// Office documents combine, in the order given, with a PDF made here, one bookmark each. +#[test] +fn office_documents_combine_in_order_with_a_bookmark_each() { + let Some((dir, mut a)) = office_files() else { return }; + let made = ok( + &mut a, + "doc_create_multiple", + json!({ "paths": ["deck.pptx", "letter.docx", "budget.xlsx", "notes.rtf"], "out": "all.pdf", "open": true }), + ); + let doc = made["document"]["doc"].as_u64().unwrap(); + let pages = page_text(&mut a, doc); + assert_eq!(pages.len(), 4, "{pages:?}"); + for (page, word) in pages.iter().zip(["Axolotl deck", "Quokka letter", "Narwhal budget", "Pangolin notes"]) { + assert!(page.contains(word), "{word:?} not in {page:?}"); + } + let titles: Vec = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"] + .as_array() + .unwrap() + .iter() + .map(|b| b["title"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(titles, ["deck", "letter", "budget", "notes"]); + assert!(dir.path().join("all.pdf").is_file()); + + // doc_combine takes them beside a PDF, with a page range for one of them. + std::fs::write(dir.path().join("cover.txt"), "Cover page").unwrap(); + ok(&mut a, "doc_create_multiple", json!({ "paths": ["cover.txt"], "out": "cover.pdf" })); + assert!(dir.path().join("cover.pdf").is_file()); + let combined = ok(&mut a, "doc_combine", json!({ "paths": ["cover.pdf", "notes.rtf", "letter.docx"], "pages": [null, "1", null], "open": true })); + let doc = combined["document"]["doc"].as_u64().unwrap(); + let pages = page_text(&mut a, doc); + assert_eq!(pages.len(), 3, "{pages:?}"); + for (page, word) in pages.iter().zip(["Cover page", "Pangolin notes", "Quokka letter"]) { + assert!(page.contains(word), "{word:?} not in {page:?}"); + } +} diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index 51eca668d0..79c7a4f276 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -24,6 +24,11 @@ thiserror = { workspace = true } # Already in the graph through lopdf; used only to read plain-text files (decode_text). encoding_rs = "0.8" +[target.'cfg(unix)'.dependencies] +# Already in the graph (pdfcraft-platform, ui-egui); stops a timed-out LibreOffice's whole process +# group, soffice.bin included, without unsafe code. +rustix = { version = "1", features = ["process"] } + [dev-dependencies] # An independent CCITT decoder, to check Create PDF's Group 4 images. hayro-ccitt.workspace = true diff --git a/crates/create/README.md b/crates/create/README.md index 95d47d27a9..4c9d1a8823 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -28,6 +28,30 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: Every function returns a `pdfcraft_cos::Document`; the caller writes it. +Office documents (`office.rs`) are the exception: PdfCraft doesn't read Word, Excel, PowerPoint, +OpenDocument or RTF itself. `is_office(name, bytes)` recognises them (an `OFFICE_EXTENSIONS` +name and an OLE, ZIP or RTF signature; `source_kind` reports `SourceKind::Office`), and +`office_to_pdf(name, bytes)` returns PDF bytes made by an installed LibreOffice, run headless as +a separate process: a private temporary folder (owner-only on Unix; the document is always +`document.` there, never its own name), a fresh profile with macros disabled, untrusted +links blocked and Writer and Calc set never to update linked content (sections, files, external +data) on load, no console window on Windows, and a stop after `TIMEOUT` (120 s) or as soon as the +output grows past 512 MB (the whole process tree is stopped, not only `soffice`). LibreOffice is +found through `PDFCRAFT_SOFFICE`, the `PATH`, then the usual install folders (`libreoffice()`). +A snap-installed LibreOffice is not among those folders: its private `/tmp` cannot see the work +folder. Where `/snap/bin` is on the `PATH` it can still be found there, so point +`PDFCRAFT_SOFFICE` at a non-snap install if conversions fail. It takes seconds, so callers keep it +off the frame thread. On the web it returns an error. + +Impress has no setting for linked content, and the profile settings are LibreOffice's own: +PdfCraft doesn't sandbox the process's network access. Converting test documents whose linked +sections, pictures and external cell references point at a local listener made no connection, +with or without the Writer and Calc settings (LibreOffice 26.8.1, Windows, 2026-10-26), so the settings are a +second line of defence rather than a measured fix. + +`IMAGE_EXTENSIONS`, `TEXT_EXTENSIONS` and `OFFICE_EXTENSIONS` are what the app's file pickers +offer, so detection and pickers agree. + Image export and Save Image As apply each lossless image's Decode pair before converting its color components. JBIG2 images and masks are decoded through `pdfcraft_cos::ImageBudget` (`pdfcraft_filters::decode_jbig2_within`), behind other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 389173d848..d6c2208a83 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -1,4 +1,4 @@ -//! pdfcraft-create — create PDFs from nothing, images or text (L4). See the README. +//! pdfcraft-create — create PDFs from nothing, images, text or (through LibreOffice) Office documents (L4). See the README. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -8,6 +8,10 @@ mod extract; mod text_font; mod upright_image; pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within}; +mod office; +#[cfg(not(target_arch = "wasm32"))] +pub use office::libreoffice; +pub use office::{OFFICE_EXTENSIONS, is_office, office_to_pdf}; use pdfcraft_fonts::wrap_with; use text_font::TextFont; @@ -17,6 +21,9 @@ pub enum CreateError { Image(String, String), #[error("{0}")] Invalid(String), + /// An Office document LibreOffice couldn't convert, or LibreOffice isn't installed. + #[error("{0}")] + Office(String), } /// US Letter in points. @@ -757,9 +764,18 @@ pub enum SourceKind { Pdf, Image, Text, + /// A Word, Excel, PowerPoint or OpenDocument file, converted through LibreOffice. + Office, } -/// File extensions Create converts to PDF (images and plain text). +/// File extensions of the images Create converts to PDF. +pub const IMAGE_EXTENSIONS: [&str; 11] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "pcx"]; + +/// File extensions of the plain-text files Create converts to PDF. +pub const TEXT_EXTENSIONS: [&str; 2] = ["txt", "text"]; + +/// File extensions Create converts to PDF itself: [`IMAGE_EXTENSIONS`] and [`TEXT_EXTENSIONS`] +/// (Office documents go through LibreOffice, [`OFFICE_EXTENSIONS`]). pub const CONVERTIBLE: [&str; 13] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "pcx", "txt", "text"]; /// A PCX file: its manufacturer byte, a version (1 was never used), run-length coding and 1 or 8 @@ -792,8 +808,8 @@ fn is_image(bytes: &[u8]) -> bool { && bytes.get(14..18).and_then(|h| <[u8; 4]>::try_from(h).ok()).is_some_and(|h| matches!(u32::from_le_bytes(h), 12 | 40 | 52 | 56 | 108 | 124))) } -/// Whether `bytes` named `name` is a PDF, an image Create can embed, or plain text (a `.txt` or -/// `.text` file). `None` for anything else. +/// Whether `bytes` named `name` is a PDF, an image Create can embed, plain text (a `.txt` or +/// `.text` file) or an Office document ([`is_office`]). `None` for anything else. pub fn source_kind(name: &str, bytes: &[u8]) -> Option { let head = bytes.get(..bytes.len().min(1024)).unwrap_or_default(); if head.windows(5).any(|w| w == b"%PDF-") { @@ -802,6 +818,9 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option { if is_image(bytes) { return Some(SourceKind::Image); } + if is_office(name, bytes) { + return Some(SourceKind::Office); + } let lower = name.to_ascii_lowercase(); (lower.ends_with(".txt") || lower.ends_with(".text")).then_some(SourceKind::Text) } diff --git a/crates/create/src/office.rs b/crates/create/src/office.rs new file mode 100644 index 0000000000..3d8332c149 --- /dev/null +++ b/crates/create/src/office.rs @@ -0,0 +1,599 @@ +//! Office documents (Word, Excel, PowerPoint, OpenDocument, RTF) to PDF through an installed +//! LibreOffice, run as a separate process: the "LibreOffice sidecar" of +//! `docs/file-format-parity.md`. PdfCraft never parses these formats itself. +//! +//! Detection ([`is_office`]) is pure and works everywhere. Conversion ([`office_to_pdf`]) runs +//! only on desktop builds: each run gets its own temporary folder and a fresh LibreOffice +//! profile with macros and untrusted links turned off, and is stopped after [`TIMEOUT`]. + +use crate::CreateError; + +/// File extensions of the Office documents converted through LibreOffice. +pub const OFFICE_EXTENSIONS: &[&str] = &[ + // Word and Writer + "doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", // + // Excel and Calc + "xls", "xlsx", "xlsm", "xlt", "xltx", "xltm", "ods", "ots", // + // PowerPoint and Impress + "ppt", "pptx", "pptm", "pot", "potx", "potm", "pps", "ppsx", "ppsm", "odp", "otp", +]; + +/// The OLE compound-file signature of Office 97–2003 files (`.doc`, `.xls`, `.ppt`). +const OLE: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + +/// The longest a conversion may run before LibreOffice is stopped. A fresh profile costs a +/// few seconds on its own; large spreadsheets take longer. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); + +/// The largest PDF read back from LibreOffice. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +const MAX_PDF: u64 = 512 * 1024 * 1024; + +/// `name`'s extension as it appears in [`OFFICE_EXTENSIONS`], if it is one. +fn office_extension(name: &str) -> Option<&'static str> { + let (_, ext) = name.rsplit_once('.')?; + OFFICE_EXTENSIONS.iter().copied().find(|e| ext.eq_ignore_ascii_case(e)) +} + +/// Whether `bytes` named `name` is an Office document: an Office extension, and the bytes start +/// like one (an OLE compound file, a ZIP package as OOXML and OpenDocument are, or RTF). +pub fn is_office(name: &str, bytes: &[u8]) -> bool { + office_extension(name).is_some() && (bytes.starts_with(OLE) || bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"{\\rtf")) +} + +/// Convert the Office document `bytes` named `name` to PDF bytes with LibreOffice. +#[cfg(target_arch = "wasm32")] +pub fn office_to_pdf(name: &str, _bytes: &[u8]) -> Result, CreateError> { + Err(CreateError::Office(format!("{name}: Office documents open only in the desktop app, which converts them with LibreOffice"))) +} + +/// Convert the Office document `bytes` named `name` to PDF bytes with LibreOffice. +#[cfg(not(target_arch = "wasm32"))] +pub fn office_to_pdf(name: &str, bytes: &[u8]) -> Result, CreateError> { + let program = native::libreoffice().ok_or_else(|| { + CreateError::Office(format!( + "{name}: opening Word, Excel and PowerPoint files needs LibreOffice, which isn't installed (get it free from libreoffice.org)" + )) + })?; + native::convert_with(&program, name, bytes, TIMEOUT) +} + +/// Where LibreOffice is installed, if it is: `PDFCRAFT_SOFFICE`, then the `PATH`, then the +/// usual install folders. +#[cfg(not(target_arch = "wasm32"))] +pub fn libreoffice() -> Option { + native::libreoffice() +} + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use super::{CreateError, MAX_PDF, office_extension}; + use std::ffi::OsString; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command, ExitStatus, Stdio}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{Duration, Instant}; + + /// The program names LibreOffice installs on the `PATH`. + #[cfg(windows)] + const PATH_NAMES: &[&str] = &["soffice.exe"]; + #[cfg(not(windows))] + const PATH_NAMES: &[&str] = &["soffice", "libreoffice"]; + + pub(super) fn libreoffice() -> Option { + let defaults = default_locations(&|key| std::env::var_os(key)); + candidates(std::env::var_os("PDFCRAFT_SOFFICE"), std::env::var_os("PATH"), defaults).into_iter().find(|p| p.is_file()) + } + + /// Where to look, in order: the override, each `PATH` folder, then `defaults`. + pub(super) fn candidates(override_path: Option, path: Option, defaults: Vec) -> Vec { + let mut out: Vec = override_path.filter(|p| !p.is_empty()).map(PathBuf::from).into_iter().collect(); + if let Some(path) = path { + for dir in std::env::split_paths(&path) { + out.extend(PATH_NAMES.iter().map(|name| dir.join(name))); + } + } + out.extend(defaults); + out + } + + /// The usual install locations, given a way to read environment variables. + #[cfg(windows)] + pub(super) fn default_locations(env: &dyn Fn(&str) -> Option) -> Vec { + ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] + .iter() + .filter_map(|key| env(key)) + .map(|dir| PathBuf::from(dir).join("LibreOffice").join("program").join("soffice.exe")) + .collect() + } + + /// The usual install locations, given a way to read environment variables. + #[cfg(target_os = "macos")] + pub(super) fn default_locations(env: &dyn Fn(&str) -> Option) -> Vec { + let app = Path::new("LibreOffice.app/Contents/MacOS/soffice"); + let mut out = vec![Path::new("/Applications").join(app)]; + out.extend(env("HOME").map(|home| PathBuf::from(home).join("Applications").join(app))); + out + } + + /// The usual install locations, given a way to read environment variables. + #[cfg(not(any(windows, target_os = "macos")))] + pub(super) fn default_locations(_env: &dyn Fn(&str) -> Option) -> Vec { + [ + "/usr/bin/soffice", + "/usr/bin/libreoffice", + "/usr/local/bin/soffice", + "/usr/local/bin/libreoffice", + "/usr/lib/libreoffice/program/soffice", + "/opt/libreoffice/program/soffice", + ] + .iter() + .map(PathBuf::from) + .collect() + } + + /// The fresh profile's settings: no macros at all, no links to untrusted locations, and + /// linked content in Writer and Calc documents (sections, files, external data) never + /// updated on load, so a document can't make LibreOffice fetch from the network or a share. + /// The two `Link` settings number "Never" differently (Writer.xcs: 0 Always, 1 On request, + /// 2 Never; Calc.xcs: 0 Always, 1 Never, 2 On request). Impress has no such setting. + pub(super) const PROFILE_SETTINGS: &str = r#" + +true +3 +true +2 +1 + +"#; + + /// A private temporary folder, removed (with everything in it) when dropped. + pub(super) struct WorkDir(pub(super) PathBuf); + + impl WorkDir { + pub(super) fn new() -> std::io::Result { + static NEXT: AtomicU64 = AtomicU64::new(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()); + let dir = std::env::temp_dir().join(format!("pdfcraft-office-{}-{n}-{nanos}", std::process::id())); + // Not recursive: a folder that already exists is not ours. Owner-only on Unix, where the + // temporary folder is shared: the document and its PDF are not for other users. + #[cfg_attr(not(unix), allow(unused_mut))] + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(&dir)?; + Ok(Self(dir)) + } + } + + impl Drop for WorkDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// `path` as a `file://` URL, which `-env:UserInstallation` takes. + pub(super) fn file_url(path: &Path) -> String { + let path = path.to_string_lossy().replace('\\', "/"); + let mut url = String::from("file://"); + // A Windows path (`C:/…`) needs the third slash a Unix path already starts with. + if !path.starts_with('/') { + url.push('/'); + } + for b in path.bytes() { + if b.is_ascii_alphanumeric() || b"-._~/:".contains(&b) { + url.push(char::from(b)); + } else { + url.push_str(&format!("%{b:02X}")); + } + } + url + } + + pub(super) enum Run { + Exited(ExitStatus), + TimedOut, + /// The watched folder grew past its limit while the command ran. + TooLarge, + } + + /// The bytes in the files directly inside `dir` (LibreOffice writes its one output there). + /// Anything unreadable counts as nothing, and the sum saturates. Each size is read from the + /// file itself: on Windows a directory listing keeps the old size while a process still + /// has the file open for writing. + pub(super) fn folder_size(dir: &Path) -> u64 { + let Ok(entries) = std::fs::read_dir(dir) else { return 0 }; + entries + .flatten() + .filter_map(|entry| std::fs::symlink_metadata(entry.path()).ok()) + .filter(|meta| meta.is_file()) + .fold(0u64, |sum, meta| sum.saturating_add(meta.len())) + } + + /// Run `command` to completion, killing it and every process it started once `timeout` + /// passes, or once the files in `watch`'s folder add up to more than its limit (so a + /// document that converts to an enormous PDF can't fill the disk before it is checked). + /// `soffice` hands the conversion to its own child, `soffice.bin`, which would + /// otherwise outlive the kill, holding the profile folder and its memory. + pub(super) fn run_limited(mut command: Command, timeout: Duration, watch: Option<(&Path, u64)>) -> std::io::Result { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Its own process group, so the whole group can be stopped together. + command.process_group(0); + } + let mut child = command.spawn()?; + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(Run::Exited(status)), + Ok(None) => {} + Err(error) => { + kill_tree(&mut child); + return Err(error); + } + } + if watch.is_some_and(|(dir, limit)| folder_size(dir) > limit) { + kill_tree(&mut child); + return Ok(Run::TooLarge); + } + if start.elapsed() >= timeout { + kill_tree(&mut child); + return Ok(Run::TimedOut); + } + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// Kill `child` and every process it started, then reap it. + fn kill_tree(child: &mut Child) { + #[cfg(unix)] + { + use rustix::process::{Pid, Signal, kill_process_group}; + // The group `process_group(0)` made has the child's id. + if let Some(group) = i32::try_from(child.id()).ok().and_then(Pid::from_raw) { + let _ = kill_process_group(group, Signal::KILL); + } + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // `/T` walks the tree from the child's id, so it runs while the child is still alive. + // Windows' own copy, not whatever `taskkill` the PATH finds first. + let taskkill = std::env::var_os("SystemRoot") + .map_or_else(|| PathBuf::from("taskkill.exe"), |root| PathBuf::from(root).join("System32").join("taskkill.exe")); + let _ = Command::new(taskkill) + .args(["/T", "/F", "/PID", &child.id().to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + // CREATE_NO_WINDOW, as for LibreOffice itself. + .creation_flags(0x0800_0000) + .status(); + } + // The child itself, if the tree kill couldn't reach it. + let _ = child.kill(); + let _ = child.wait(); + } + + /// Convert with the LibreOffice at `program`, giving up after `timeout`. + pub(super) fn convert_with(program: &Path, name: &str, bytes: &[u8], timeout: Duration) -> Result, CreateError> { + convert_capped(program, name, bytes, timeout, MAX_PDF) + } + + /// [`convert_with`] with the converted PDF's size limit given: the output folder is watched + /// while LibreOffice runs, and the finished file is checked again. + pub(super) fn convert_capped(program: &Path, name: &str, bytes: &[u8], timeout: Duration, max_pdf: u64) -> Result, CreateError> { + let too_large = || format!("the converted PDF is larger than {} MB", max_pdf / (1024 * 1024)); + let failed = |why: String| CreateError::Office(format!("{name}: {why}")); + let ext = office_extension(name).ok_or_else(|| failed("this isn't a Word, Excel or PowerPoint file".into()))?; + let work = WorkDir::new().map_err(|e| failed(format!("couldn't make a temporary folder: {e}")))?; + // A fixed input name: the document's own name never reaches LibreOffice's command line. + let input = work.0.join(format!("document.{ext}")); + let profile = work.0.join("profile"); + let out = work.0.join("out"); + let prepared = std::fs::write(&input, bytes) + .and_then(|()| std::fs::create_dir_all(profile.join("user"))) + .and_then(|()| std::fs::write(profile.join("user").join("registrymodifications.xcu"), PROFILE_SETTINGS)) + .and_then(|()| std::fs::create_dir(&out)); + prepared.map_err(|e| failed(format!("couldn't prepare the conversion: {e}")))?; + + let mut command = Command::new(program); + command + .arg(format!("-env:UserInstallation={}", file_url(&profile))) + .args(["--headless", "--invisible", "--nologo", "--norestore", "--nodefault", "--nolockcheck", "--convert-to", "pdf", "--outdir"]) + .arg(&out) + .arg(&input) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // CREATE_NO_WINDOW: no console window flashes up during the conversion. + command.creation_flags(0x0800_0000); + } + let status = match run_limited(command, timeout, Some((&out, max_pdf))) { + Ok(Run::Exited(status)) => status, + Ok(Run::TooLarge) => return Err(failed(format!("{}; LibreOffice was stopped", too_large()))), + Ok(Run::TimedOut) => return Err(failed(format!("LibreOffice took longer than {} seconds and was stopped", timeout.as_secs()))), + Err(e) => return Err(failed(format!("couldn't start LibreOffice ({}): {e}", program.display()))), + }; + let pdf = out.join("document.pdf"); + let size = match std::fs::metadata(&pdf) { + Ok(meta) => meta.len(), + Err(_) if !status.success() => return Err(failed(format!("LibreOffice couldn't convert it ({status})"))), + Err(_) => return Err(failed("LibreOffice didn't produce a PDF; the file may be damaged or password-protected".into())), + }; + if size > max_pdf { + return Err(failed(too_large())); + } + let bytes = std::fs::read(&pdf).map_err(|e| failed(format!("couldn't read the converted PDF: {e}")))?; + if !bytes.starts_with(b"%PDF-") { + return Err(failed("LibreOffice's output isn't a PDF".into())); + } + Ok(bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DOCX_HEAD: &[u8] = b"PK\x03\x04\x14\x00\x06\x00"; + + #[test] + fn office_files_are_recognised_by_extension_and_signature() { + assert!(is_office("Report.docx", DOCX_HEAD)); + assert!(is_office("SHEET.XLSX", DOCX_HEAD)); + assert!(is_office("deck.odp", DOCX_HEAD)); + assert!(is_office("old.doc", OLE)); + assert!(is_office("old.ppt", &[OLE, b"rest"].concat())); + assert!(is_office("letter.rtf", b"{\\rtf1\\ansi")); + // Word saves RTF under .doc too. + assert!(is_office("letter.doc", b"{\\rtf1\\ansi")); + } + + #[test] + fn other_files_are_not_office_documents() { + // The right signature with another extension, or an Office name with other bytes. + assert!(!is_office("archive.zip", DOCX_HEAD)); + assert!(!is_office("Report.docx", b"%PDF-1.7")); + assert!(!is_office("Report.docx", b"plain text")); + assert!(!is_office("Report.docx", b"")); + assert!(!is_office("docx", DOCX_HEAD)); + assert!(!is_office("", DOCX_HEAD)); + // A multi-byte name never splits a character. + assert!(!is_office("résumé.dócx", DOCX_HEAD)); + assert!(is_office("résumé.docx", DOCX_HEAD)); + } + + #[cfg(not(target_arch = "wasm32"))] + mod native { + use super::super::native::*; + use super::DOCX_HEAD; + use std::ffi::OsString; + use std::path::{Path, PathBuf}; + use std::time::Duration; + + #[test] + fn file_urls_escape_spaces_and_keep_drive_letters() { + assert_eq!(file_url(Path::new("/tmp/a b/profile")), "file:///tmp/a%20b/profile"); + assert_eq!(file_url(Path::new(r"C:\Users\Jo Ann\profile")), "file:///C:/Users/Jo%20Ann/profile"); + assert_eq!(file_url(Path::new("/tmp/é")), "file:///tmp/%C3%A9"); + } + + #[test] + fn the_override_comes_first_then_the_path_then_the_defaults() { + let path = std::env::join_paths([Path::new("first"), Path::new("second")]).unwrap(); + let found = candidates(Some(OsString::from("custom/soffice")), Some(path), vec![PathBuf::from("default/soffice")]); + assert_eq!(found.first(), Some(&PathBuf::from("custom/soffice"))); + assert!(found.get(1).is_some_and(|p| p.starts_with("first"))); + assert_eq!(found.last(), Some(&PathBuf::from("default/soffice"))); + // An empty override is no override. + assert_eq!(candidates(Some(OsString::new()), None, Vec::new()), Vec::::new()); + } + + #[test] + fn default_locations_read_only_the_given_environment() { + let found = default_locations(&|_| None); + assert!(found.iter().all(|p| p.is_absolute()), "{found:?}"); + } + + /// Each setting the fresh profile makes, as (path, property, value). + fn profile_settings() -> Vec<(&'static str, &'static str, &'static str)> { + let between = |s: &'static str, start: &str, end: &str| s.split_once(start).and_then(|(_, rest)| rest.split_once(end)).map(|(v, _)| v); + PROFILE_SETTINGS + .split("", "")?)) + }) + .collect() + } + + #[test] + fn the_profile_turns_off_macros_and_never_updates_links() { + let settings = profile_settings(); + let scripting = "/org.openoffice.Office.Common/Security/Scripting"; + assert!(settings.contains(&(scripting, "DisableMacrosExecution", "true")), "{settings:?}"); + assert!(settings.contains(&(scripting, "MacroSecurityLevel", "3")), "{settings:?}"); + assert!(settings.contains(&(scripting, "BlockUntrustedRefererLinks", "true")), "{settings:?}"); + // "Never" is 2 for Writer but 1 for Calc (Writer.xcs and Calc.xcs); Writer's 1 is + // its default, "On request". + assert!(settings.contains(&("/org.openoffice.Office.Writer/Content/Update", "Link", "2")), "{settings:?}"); + assert!(settings.contains(&("/org.openoffice.Office.Calc/Content/Update", "Link", "1")), "{settings:?}"); + assert_eq!(settings.len(), 5, "{settings:?}"); + } + + #[test] + fn a_missing_converter_is_an_error_not_a_crash() { + let missing = std::env::temp_dir().join("pdfcraft-no-such-soffice").join("soffice"); + let error = convert_with(&missing, "Report.docx", DOCX_HEAD, Duration::from_secs(5)).unwrap_err(); + assert!(error.to_string().contains("couldn't start LibreOffice"), "{error}"); + } + + #[test] + fn a_converter_that_makes_no_pdf_is_reported() { + // This test binary stands in for LibreOffice: it rejects the arguments and exits + // without writing anything. + let program = std::env::current_exe().unwrap(); + let error = convert_with(&program, "Report.docx", DOCX_HEAD, Duration::from_secs(60)).unwrap_err(); + assert!(error.to_string().contains("LibreOffice"), "{error}"); + } + + #[test] + fn a_converter_that_hangs_is_stopped() { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command.args(["--ignored", "--exact", "office::tests::native::sleeps_for_a_minute", "--nocapture"]); + command.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + let started = std::time::Instant::now(); + assert!(matches!(run_limited(command, Duration::from_millis(500), None), Ok(Run::TimedOut))); + assert!(started.elapsed() < Duration::from_secs(30)); + } + + /// The stand-in for a LibreOffice that never finishes (run only by the test above). + #[test] + #[ignore] + fn sleeps_for_a_minute() { + std::thread::sleep(Duration::from_secs(60)); + } + + /// Where the stand-ins below write their heartbeat. + const HEARTBEAT: &str = "PDFCRAFT_TEST_OFFICE_HEARTBEAT"; + + /// This test binary run as one of its own ignored tests, with the heartbeat file set. + fn stand_in(test: &str, heartbeat: &Path) -> std::process::Command { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command.args(["--ignored", "--exact", test, "--nocapture"]).env(HEARTBEAT, heartbeat); + command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + command + } + + /// `soffice` starts `soffice.bin` as its own child: a timeout must stop both, or the + /// child keeps running, holding the profile folder. + #[test] + fn a_timeout_stops_the_processes_the_converter_started() { + let work = WorkDir::new().unwrap(); + let heartbeat = work.0.join("heartbeat"); + let parent = stand_in("office::tests::native::starts_a_child_that_keeps_beating", &heartbeat); + assert!(matches!(run_limited(parent, Duration::from_secs(5), None), Ok(Run::TimedOut))); + let beats = || std::fs::metadata(&heartbeat).map_or(0, |m| m.len()); + // A killed process may take a moment to go. + std::thread::sleep(Duration::from_millis(300)); + let after_kill = beats(); + assert!(after_kill > 0, "the grandchild started and was beating before the timeout"); + std::thread::sleep(Duration::from_secs(1)); + assert_eq!(beats(), after_kill, "the grandchild was stopped with its parent"); + } + + /// The stand-in for `soffice`: starts [`beats_until_stopped`] and waits (run only by the + /// test above). + #[test] + #[ignore] + fn starts_a_child_that_keeps_beating() { + let Some(heartbeat) = std::env::var_os(HEARTBEAT) else { return }; + let mut child = stand_in("office::tests::native::beats_until_stopped", Path::new(&heartbeat)).spawn().unwrap(); + std::thread::sleep(Duration::from_secs(60)); + let _ = child.kill(); + let _ = child.wait(); + } + + /// The stand-in for `soffice.bin`: appends to the heartbeat file every 50 ms for a minute. + #[test] + #[ignore] + fn beats_until_stopped() { + use std::io::Write; + let Some(heartbeat) = std::env::var_os(HEARTBEAT) else { return }; + let mut file = std::fs::OpenOptions::new().create(true).append(true).open(heartbeat).unwrap(); + for _ in 0..1200 { + file.write_all(b".").unwrap(); + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// Where the stand-in below writes. + const FILL_DIR: &str = "PDFCRAFT_TEST_OFFICE_FILL_DIR"; + + /// A converter that writes without end is stopped as soon as its output passes the + /// limit, not after it has run out its time. + #[test] + fn a_converter_whose_output_grows_past_the_limit_is_stopped() { + let work = WorkDir::new().unwrap(); + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command.args(["--ignored", "--exact", "office::tests::native::writes_without_end", "--nocapture"]).env(FILL_DIR, &work.0); + command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + let limit = 256 * 1024; + let started = std::time::Instant::now(); + let run = run_limited(command, Duration::from_secs(60), Some((&work.0, limit))); + assert!(matches!(run, Ok(Run::TooLarge)), "stopped for size, not for time"); + assert!(started.elapsed() < Duration::from_secs(30), "stopped long before the timeout"); + // A killed process may take a moment to go; after that nothing is written. + std::thread::sleep(Duration::from_millis(300)); + let after_kill = folder_size(&work.0); + assert!(after_kill > limit, "the stand-in had written past the limit"); + std::thread::sleep(Duration::from_secs(1)); + assert_eq!(folder_size(&work.0), after_kill, "the converter was stopped"); + } + + /// The stand-in for a LibreOffice that fills the disk: 64 KiB every 10 ms for a minute + /// (run only by the test above). + #[test] + #[ignore] + fn writes_without_end() { + use std::io::Write; + let Some(dir) = std::env::var_os(FILL_DIR) else { return }; + let mut file = std::fs::File::create(Path::new(&dir).join("document.pdf")).unwrap(); + let chunk = vec![0u8; 64 * 1024]; + for _ in 0..6000 { + file.write_all(&chunk).unwrap(); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn a_folder_that_cannot_be_read_counts_as_empty() { + assert_eq!(folder_size(&std::env::temp_dir().join("pdfcraft-no-such-folder")), 0); + } + + /// The temporary folder holds a confidential document and its PDF: on Unix only the owner + /// may enter it. + #[cfg(unix)] + #[test] + fn the_temporary_folder_is_private_to_its_owner() { + use std::os::unix::fs::PermissionsExt; + let work = WorkDir::new().unwrap(); + let mode = std::fs::metadata(&work.0).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o700, "{mode:o}"); + } + + #[test] + fn temporary_folders_are_removed_with_their_contents() { + let work = WorkDir::new().unwrap(); + let other = WorkDir::new().unwrap(); + assert_ne!(work.0, other.0); + let dir = work.0.clone(); + std::fs::create_dir_all(dir.join("profile").join("user")).unwrap(); + std::fs::write(dir.join("document.docx"), DOCX_HEAD).unwrap(); + drop(work); + assert!(!dir.exists()); + } + + /// A real conversion, when LibreOffice is installed (skipped otherwise). + #[test] + fn libreoffice_converts_a_document_when_installed() { + let Some(program) = super::super::libreoffice() else { + eprintln!("skipped: LibreOffice isn't installed"); + return; + }; + let rtf = b"{\\rtf1\\ansi{\\fonttbl\\f0\\fswiss Helvetica;}\\f0\\pard Hello from PdfCraft.\\par}"; + let pdf = convert_with(&program, "hello.rtf", rtf, super::super::TIMEOUT).unwrap(); + assert!(pdf.starts_with(b"%PDF-")); + } + } +} diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index d2a8c143e7..f8b870832b 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -559,7 +559,7 @@ fn source_kind_tells_pdfs_images_and_text_apart() { // Text that merely starts like a BMP stays text; a truncated BMP header is not an image. assert_eq!(source_kind("b.txt", b"BMW drivers"), Some(SourceKind::Text)); assert_eq!(source_kind("b.bmp", b"BM"), None); - assert_eq!(source_kind("a.docx", b"PK\x03\x04"), None); + assert_eq!(source_kind("a.docx", b"PK\x03\x04"), Some(SourceKind::Office), "Office documents convert through LibreOffice"); assert_eq!(source_kind("", b""), None); // Only the GIF versions Create decodes count as images, so detection never promises a // conversion that then fails. @@ -2952,3 +2952,19 @@ fn stripping_extended_xmp_leaves_the_rest_of_the_jpeg() { // The helper puts a stray byte before the chunks, which the decoder skips: it stays. assert_eq!(crate::upright_image::without_extended_xmp(&flooded), [&plain[..2], b"\0".as_slice(), &plain[2..]].concat()); } + +#[test] +fn convertible_is_the_image_and_text_extensions() { + let both: Vec<&str> = IMAGE_EXTENSIONS.iter().chain(TEXT_EXTENSIONS.iter()).copied().collect(); + assert_eq!(both, CONVERTIBLE.to_vec()); + assert!(OFFICE_EXTENSIONS.iter().all(|e| !CONVERTIBLE.contains(e) && *e != "pdf")); +} + +#[test] +fn office_documents_are_their_own_kind() { + assert_eq!(source_kind("Report.docx", b"PK\x03\x04\x14\x00"), Some(SourceKind::Office)); + assert_eq!(source_kind("Budget.xls", &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]), Some(SourceKind::Office)); + // A PDF saved with a Word name is still a PDF; a ZIP that isn't named like Office is nothing. + assert_eq!(source_kind("Report.docx", b"%PDF-1.7\n"), Some(SourceKind::Pdf)); + assert_eq!(source_kind("photos.zip", b"PK\x03\x04\x14\x00"), None); +} diff --git a/crates/engine/src/document_actions_tests.rs b/crates/engine/src/document_actions_tests.rs new file mode 100644 index 0000000000..8511163eff --- /dev/null +++ b/crates/engine/src/document_actions_tests.rs @@ -0,0 +1,379 @@ +//! Catalog JavaScript actions (#269, ported from #344): the `/OpenAction` runs once on opening +//! and again on Revert, `/AA /WS` before a save and `/AA /WP` before printing, all in the +//! document's retained realm. + +use std::sync::Arc; + +use crate::js::DocumentScriptEvent; +use crate::{DocId, Session}; + +/// A PDF of `objs`, numbered from 1, with a cross-reference table. +fn pdf_of(objs: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A one-field form (`log`, a text field) whose catalog carries `catalog` (PDF syntax, e.g. +/// `/OpenAction 6 0 R`), with `extra` objects numbered from 6. +fn form(catalog: &str, extra: &[&str]) -> Vec { + let mut objs = vec![ + format!("<< /Type /Catalog /Pages 2 0 R {catalog} /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>"), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + objs.extend(extra.iter().map(|s| s.to_string())); + pdf_of(&objs) +} + +fn log(s: &Session, id: DocId) -> String { + s.get(id).unwrap().form.iter().find(|f| f.name == "log").unwrap().value.join(",") +} + +/// The open action runs once on opening, after the document scripts and in their realm, and +/// sees `event.name` "Open"; its field change is applied. +#[test] +fn the_open_action_runs_once_on_opening_in_the_document_realm() { + let bytes = form( + "/OpenAction 6 0 R /Names << /JavaScript << /Names [(init) 7 0 R] >> >>", + &[ + "<< /S /JavaScript /JS (opened++; getField\\('log'\\).value = event.type + '/' + event.name + '/' + opened;) >>", + "<< /S /JavaScript /JS (var opened = 0;) >>", + ], + ); + let mut s = Session::new(); + let id = s.open("open.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(log(&s, id), "Doc/Open/1"); + assert!(s.take_js_output(id).errors.is_empty()); + // The realm is kept: a later script sees the count the open action left. + assert_eq!(s.run_javascript(id, "opened;", None).unwrap().result.as_deref(), Some("1")); +} + +/// An open action chain runs in order through `/Next` (a dictionary or an array), skipping +/// actions of other types, and a cycle in it runs each action once. +#[test] +fn an_open_action_chain_runs_in_order_and_a_cycle_runs_each_action_once() { + let bytes = form( + "/OpenAction 6 0 R", + &[ + "<< /S /JavaScript /JS (var seen = ['a'];) /Next [7 0 R 8 0 R] >>", + "<< /S /GoTo /D [3 0 R /Fit] /Next 9 0 R >>", + "<< /S /JavaScript /JS (seen.push\\('c'\\); getField\\('log'\\).value = seen.join\\(''\\);) /Next 6 0 R >>", + "<< /S /JavaScript /JS (seen.push\\('b'\\);) /Next 7 0 R >>", + ], + ); + let mut s = Session::new(); + let id = s.open("chain.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(log(&s, id), "abc"); +} + +/// What an open action asks for that needs a click (a URL, printing) is dropped; a page +/// change it asks for is kept. +#[test] +fn an_open_action_may_not_open_a_url_or_print() { + let bytes = form("/OpenAction << /S /JavaScript /JS (app.launchURL\\('https://example.com'\\); this.print\\(\\); this.pageNum = 0;) >>", &[]); + let mut s = Session::new(); + let id = s.open("asks.pdf", None, Arc::new(bytes), None).unwrap(); + let out = s.take_js_output(id); + assert_eq!(out.requests, [crate::js::Request::GoToPage(0)], "{out:?}"); +} + +/// With JavaScript off the open action doesn't run. +#[test] +fn the_open_action_does_not_run_with_javascript_off() { + let bytes = form("/OpenAction << /S /JavaScript /JS (getField\\('log'\\).value = 'ran';) >>", &[]); + let mut s = Session::new(); + s.set_javascript(false); + let id = s.open("off.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(log(&s, id), ""); + assert!(s.run_document_actions(id, DocumentScriptEvent::CatalogOpen).is_empty()); +} + +/// Revert reopens the saved file in a fresh realm, so the open action runs again from scratch. +#[test] +fn revert_runs_the_open_action_again_in_a_fresh_realm() { + let bytes = form( + "/OpenAction << /S /JavaScript /JS (if \\(typeof runs == 'undefined'\\) var runs = 0; runs++; getField\\('log'\\).value = 'run ' + runs;) >>", + &[], + ); + let mut s = Session::new(); + let id = s.open("revert.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(log(&s, id), "run 1"); + s.run_javascript(id, "getField('log').value = 'typed';", None).unwrap(); + s.revert(id).unwrap(); + assert_eq!(log(&s, id), "run 1", "a fresh realm counts from zero again"); +} + +/// Will Save runs in the document realm before the bytes are written, and what it changes is in +/// the saved file; a failing Will Save is reported and the save still has bytes to write. +#[test] +fn will_save_changes_what_is_saved_and_a_failing_one_does_not_stop_the_save() { + let bytes = form( + "/Names << /JavaScript << /Names [(init) 6 0 R] >> >> /AA << /WS << /S /JavaScript /JS (saves++; getField\\('log'\\).value = event.name + ' ' + saves;) >> >>", + &["<< /S /JavaScript /JS (var saves = 0;) >>"], + ); + let mut s = Session::new(); + let id = s.open("save.pdf", None, Arc::new(bytes), None).unwrap(); + let out = s.run_document_actions(id, DocumentScriptEvent::WillSave); + assert!(out.errors.is_empty(), "{out:?}"); + assert_eq!(log(&s, id), "WillSave 1"); + let saved = s.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + reopened.set_javascript(false); + let again = reopened.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(log(&reopened, again), "WillSave 1"); + + let failing = form("/AA << /WS << /S /JavaScript /JS (throw new Error\\('no'\\);) >> >>", &[]); + let id = s.open("fail.pdf", None, Arc::new(failing), None).unwrap(); + let out = s.run_document_actions(id, DocumentScriptEvent::WillSave); + assert!(out.errors.iter().any(|e| e.contains("no")), "{out:?}"); + assert!(s.save_bytes(id).is_ok()); +} + +/// Will Print runs before printing, in the same realm, and sees `event.name` "WillPrint". +#[test] +fn will_print_runs_in_the_document_realm() { + let bytes = form("/AA << /WP << /S /JavaScript /JS (getField\\('log'\\).value = event.name;) >> >>", &[]); + let mut s = Session::new(); + let id = s.open("print.pdf", None, Arc::new(bytes), None).unwrap(); + assert!(s.run_document_actions(id, DocumentScriptEvent::WillSave).is_empty(), "no Will Save action: nothing runs"); + assert_eq!(log(&s, id), ""); + s.run_document_actions(id, DocumentScriptEvent::WillPrint); + assert_eq!(log(&s, id), "WillPrint"); +} + +/// A form with a text field `log` and a text field `hot` whose widget has JavaScript for every +/// pointer and focus trigger, each appending `event.name` to `log`. +fn triggers_form() -> Vec { + let add = + || "<< /S /JavaScript /JS (var l = getField\\('log'\\); l.value = l.value + event.name + ':' + event.target.name + ';';) >>".to_string(); + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E {} /X {} /D {} /U {} /Fo {} /Bl {} >> >>", + add(), + add(), + "<< /S /JavaScript /JS (getField\\('log'\\).value += 'D;'; this.print\\(\\);) >>", + add(), + add(), + "<< /S /JavaScript /JS (getField\\('log'\\).value += 'Bl;'; this.print\\(\\);) >>", + ), + ]; + pdf_of(&objs) +} + +/// Pointer and focus triggers run the widget's JavaScript with the event names Acrobat uses, +/// each as one undoable edit; a field without such a script runs nothing. +#[test] +fn pointer_and_focus_triggers_run_the_widget_scripts() { + use crate::js::FieldFocus; + use pdfcraft_forms::Trigger; + let mut s = Session::new(); + let id = s.open("hot.pdf", None, Arc::new(triggers_form()), None).unwrap(); + assert!(s.field_trigger(id, "hot", 0, Trigger::MouseEnter).unwrap()); + assert!(s.field_focus(id, "hot", 0, FieldFocus::Enter).unwrap()); + assert!(s.field_trigger(id, "hot", 0, Trigger::MouseUp).unwrap()); + assert!(s.field_trigger(id, "hot", 0, Trigger::MouseExit).unwrap()); + assert_eq!(log(&s, id), "Mouse Enter:hot;Focus:hot;Mouse Up:hot;Mouse Exit:hot;"); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + assert!(!s.field_trigger(id, "log", 0, Trigger::MouseEnter).unwrap(), "no script: nothing runs"); + assert!(!s.field_trigger(id, "hot", 5, Trigger::MouseEnter).unwrap(), "no such widget: nothing runs"); + assert!(s.field_trigger(id, "missing", 0, Trigger::MouseEnter).is_err()); + s.set_javascript(false); + assert!(!s.field_trigger(id, "hot", 0, Trigger::MouseEnter).unwrap(), "JavaScript off"); +} + +/// A click's Mouse Down may ask to print; losing the focus, which happens unasked, may not. +#[test] +fn only_a_click_trigger_may_print() { + use crate::js::{FieldFocus, Request}; + use pdfcraft_forms::Trigger; + let mut s = Session::new(); + let id = s.open("hot.pdf", None, Arc::new(triggers_form()), None).unwrap(); + s.field_trigger(id, "hot", 0, Trigger::MouseDown).unwrap(); + assert_eq!(s.take_js_output(id).requests, [Request::Print]); + s.field_focus(id, "hot", 0, FieldFocus::Exit).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "{out:?}"); + assert_eq!(log(&s, id), "D;Bl;"); +} + +/// `setItems` and `setAction` are written to the document: the combo box gets its new items +/// (pairs where text and export value differ), the installed scripts run on their triggers, and +/// both survive a save (#269, from #344). +#[test] +fn set_items_and_set_action_are_written_to_the_document() { + use pdfcraft_forms::Trigger; + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (size) /Opt [(Small) (Large)] /V (Large) /Rect [20 200 180 220] /P 3 0 R /F 4 >>" + .into(), + ]; + let mut s = Session::new(); + let id = s.open("items.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + let out = s + .run_javascript( + id, + "var f = getField('size'); f.setItems([['Medium', 'm'], 'Large']); \ + f.setAction('MouseEnter', \"getField('log').value = 'entered ' + event.target.value;\");", + None, + ) + .unwrap(); + assert_eq!(out.error, None, "{out:?}"); + let size = s.get(id).unwrap().form.iter().find(|f| f.name == "size").unwrap().clone(); + assert_eq!(size.options, [("m".to_string(), "Medium".to_string()), ("Large".into(), "Large".into())]); + assert_eq!(size.value, ["Large"], "the value is still an item: it stays"); + assert!(s.field_trigger(id, "size", 0, Trigger::MouseEnter).unwrap()); + assert_eq!(log(&s, id), "entered Large"); + s.undo(id).unwrap(); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "size").unwrap().options[0].1, "Small", "undone"); + s.redo(id).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let mut again = Session::new(); + let reopened = again.open("saved.pdf", None, saved, None).unwrap(); + let size = again.get(reopened).unwrap().form.iter().find(|f| f.name == "size").unwrap().clone(); + assert_eq!(size.options[0], ("m".to_string(), "Medium".to_string())); + assert!(again.field_trigger(reopened, "size", 0, Trigger::MouseEnter).unwrap(), "the installed script is in the file"); +} + +/// A check box's Mouse Up, run after the click stored the new value, reads that value and can +/// show or hide another field by it (#247). +#[test] +fn a_check_box_mouse_up_reads_the_value_its_click_stored() { + use crate::{Edit, FieldValue}; + use pdfcraft_forms::Trigger; + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (strike) /V /Off /AS /Off /Rect [20 150 50 180] /P 3 0 R /F 4 /MK << /CA (4) >> /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> /A << /S /JavaScript /JS (getField\\('note'\\).display = event.target.value == 'Off' ? display.hidden : display.visible; getField\\('log'\\).value += 'up:' + event.target.value + ';';) >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /V (struck) /Rect [60 150 180 180] /P 3 0 R /F 6 >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 0 >>\nstream\n\nendstream".into(), + ]; + let mut s = Session::new(); + let id = s.open("strike.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "strike".into(), value: FieldValue::Text("Yes".into()) }).unwrap(); + let ran = s.field_trigger(id, "strike", 0, Trigger::MouseUp); + let out = s.take_js_output(id); + assert!(ran.unwrap(), "{out:?}"); + assert_eq!(log(&s, id), "up:Yes;"); + let note = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "note").unwrap().widgets[0].hidden; + assert!(!note(&s), "shown by the script"); + s.apply(id, Edit::SetFieldValue { name: "strike".into(), value: FieldValue::Check(false) }).unwrap(); + assert!(s.field_trigger(id, "strike", 0, Trigger::MouseUp).unwrap()); + assert_eq!(log(&s, id), "up:Yes;up:Off;"); + assert!(note(&s), "hidden again"); +} + +/// A widget action's `/Next` chain runs too, each JavaScript action in order (depth first), past +/// an action of another type at its head. +#[test] +fn a_widget_actions_next_chain_runs_each_script_in_order() { + use pdfcraft_forms::Trigger; + let js = |text: &str, next: &str| format!("<< /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) {next} >>"); + let chain = format!("<< /S /URI /URI (https://example.com/) /Next [{} {}] >>", js("a;", &format!("/Next {}", js("b;", ""))), js("c;", "")); + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E {chain} >> >>"), + ]; + let mut s = Session::new(); + let id = s.open("chain.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + assert!(s.field_trigger(id, "hot", 0, Trigger::MouseEnter).unwrap()); + assert_eq!(log(&s, id), "a;b;c;"); +} + +/// `setAction` on a field that shares its name with another installs the script on that field, +/// the one the script's object stands for, not the first of the name. +#[test] +fn set_action_installs_on_the_field_the_object_stands_for() { + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 >>".into(), + ]; + let mut s = Session::new(); + let id = s.open("dup.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + let second = crate::js::field_object_id(pdfcraft_cos::ObjRef::new(6, 0)); + let out = + s.run_javascript_answers_target(id, "event.target.setAction('Format', \"event.value = 'x';\");", Some("dup"), Some(second), &[]).unwrap(); + assert_eq!(out.error, None, "{out:?}"); + let cos = s.get(id).unwrap().editor.as_ref().unwrap().cos.clone(); + let formats: Vec<(u32, bool)> = pdfcraft_forms::fields(&cos).iter().map(|f| (f.obj.num, f.actions.scripts.format.is_some())).collect(); + assert_eq!(formats, [(4, false), (6, true)]); +} + +/// Of two fields sharing a name, the one the pointer is on runs its own widget script. +#[test] +fn a_trigger_on_a_field_sharing_a_name_runs_that_fields_script() { + use pdfcraft_forms::Trigger; + let enter = |text: &str| format!("/AA << /E << /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) >> >>"); + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>" + .to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 {} >>", enter("first;")), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 150 180 170] /P 3 0 R /F 4 {} >>", enter("second;")), + ]; + let mut s = Session::new(); + let id = s.open("dup.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + let second = crate::js::field_object_id(pdfcraft_cos::ObjRef::new(7, 0)); + assert!(s.field_trigger_target(id, "dup", Some(second), 0, Trigger::MouseEnter).unwrap()); + assert_eq!(log(&s, id), "second;"); + assert!(s.field_trigger(id, "dup", 0, Trigger::MouseEnter).unwrap(), "by name alone, the first"); + assert_eq!(log(&s, id), "second;first;"); +} + +/// Focus too: of two fields sharing a name, the one that gained focus runs its On Focus script. +#[test] +fn focus_on_a_field_sharing_a_name_runs_that_fields_script() { + use crate::js::FieldFocus; + let focus = |text: &str| format!("/AA << /Fo << /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) >> >>"); + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>" + .to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 {} >>", focus("first;")), + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 150 180 170] /P 3 0 R /F 4 {} >>", focus("second;")), + ]; + let mut s = Session::new(); + let id = s.open("dup.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + let second = crate::js::field_object_id(pdfcraft_cos::ObjRef::new(7, 0)); + assert!(s.field_focus_target(id, "dup", Some(second), 0, FieldFocus::Enter).unwrap()); + assert_eq!(log(&s, id), "second;"); +} diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index d0fb11529a..a6fbea8abe 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -409,6 +409,8 @@ fn changes( text_color: a.text_color.as_deref().filter(|_| a.text_color != b.text_color).map(<[_]>::to_vec), fill_color: a.fill_color.as_deref().filter(|_| a.fill_color != b.fill_color).map(<[_]>::to_vec), caption: (a.caption != b.caption || (a.caption_written && b.caption_mixed)).then(|| a.caption.clone()).flatten(), + options: (a.options != b.options).then(|| a.options.clone()), + actions: a.actions.clone(), }; (c.value.is_some() || c.read_only.is_some() @@ -423,7 +425,9 @@ fn changes( || c.line_width.is_some() || c.border_style.is_some() || c.user_name.is_some() - || c.icon.is_some()) + || c.icon.is_some() + || c.options.is_some() + || !c.actions.is_empty()) .then_some(c) }) .collect() @@ -831,6 +835,23 @@ impl DocumentScriptEvent { Self::WillPrint => "WillPrint", } } + + /// Where the catalog keeps this event's actions. + fn trigger(self) -> pdfcraft_forms::DocumentTrigger { + match self { + Self::CatalogOpen => pdfcraft_forms::DocumentTrigger::Open, + Self::WillSave => pdfcraft_forms::DocumentTrigger::WillSave, + Self::WillPrint => pdfcraft_forms::DocumentTrigger::WillPrint, + } + } +} + +/// Keep only what a script that ran unasked (on opening, before saving or printing) may ask +/// the viewer for: highlighting, zoom, focus and the page shown. Opening a URL, a file, mail or +/// a dialog needs a click. +fn unasked_requests(requests: &mut Vec) { + requests + .retain(|r| matches!(r, Request::Highlight(_) | Request::Zoom(_) | Request::Focus(_) | Request::FocusField { .. } | Request::GoToPage(_))); } impl Session { @@ -882,13 +903,33 @@ impl Session { /// change runs it) isn't run a second time. Returns whether the document changed; what the /// scripts showed or asked for is in [`Session::take_js_output`]. pub fn field_focus(&mut self, id: DocId, name: &str, widget: usize, focus: FieldFocus) -> Result { + self.field_focus_target(id, name, None, widget, focus) + } + + /// [`Session::field_focus`] for the field object `target` ([`field_object_id`]) when given: + /// of fields sharing `name`, the one that gained or lost focus runs its own script. + pub fn field_focus_target(&mut self, id: DocId, name: &str, target: Option, widget: usize, focus: FieldFocus) -> Result { if self.js_off { return Ok(false); } let doc = self.doc_mut(id)?; - let Some(tpl) = doc.xfa_template.clone().filter(|t| pdfcraft_xfa::has_focus_scripts(t)) else { return Ok(false) }; + let Some(tpl) = doc.xfa_template.clone().filter(|t| pdfcraft_xfa::has_focus_scripts(t)) else { + // An AcroForm widget's own On Focus / On Blur JavaScript (#269). + let trigger = match focus { + FieldFocus::Enter => pdfcraft_forms::Trigger::OnFocus, + FieldFocus::Exit => pdfcraft_forms::Trigger::OnBlur, + }; + return self.field_trigger_target(id, name, target, widget, trigger); + }; let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Ok(false) }; - let Some(som) = doc.form.iter().find(|f| f.name == name).and_then(|f| crate::xfa::widget_som(cos, f, widget)) else { return Ok(false) }; + let Some(som) = doc + .form + .iter() + .find(|f| f.name == name && target.is_none_or(|t| field_object_id(f.obj) == t)) + .and_then(|f| crate::xfa::widget_som(cos, f, widget)) + else { + return Ok(false); + }; let activity = match focus { FieldFocus::Enter => "enter", FieldFocus::Exit => "exit", @@ -906,6 +947,85 @@ impl Session { Ok(self.get(id).is_some_and(|d| d.generation != before)) } + /// Widget `widget` (0-based) of AcroForm field `name` in document `id` saw `trigger`: the + /// pointer entered, left, pressed or released it, or it gained or lost the focus. Run that + /// widget's JavaScript action for the trigger, if it has one, in the document's realm, with + /// `event.type` "Field" and `event.name` "Mouse Enter", "Mouse Exit", "Mouse Down", "Mouse + /// Up", "Focus" or "Blur" (#269, ported from #344), then those of the actions chained after + /// it (`/Next`). What each action changes is one undoable edit. + /// Returns whether the document changed; what the script showed or asked for is in + /// [`Session::take_js_output`]. Pointer and focus scripts run unasked, so they may not + /// print, open Save As or show a panel; Mouse Down and Mouse Up come from a click and may. + /// Nothing runs on an XFA form (its own events run instead), with JavaScript off, or for a + /// widget with no JavaScript for the trigger. + pub fn field_trigger(&mut self, id: DocId, name: &str, widget: usize, trigger: pdfcraft_forms::Trigger) -> Result { + self.field_trigger_target(id, name, None, widget, trigger) + } + + /// [`Session::field_trigger`] for the field object `target` ([`field_object_id`]) when given: + /// of fields sharing `name`, the one the pointer is on runs its own script. + pub fn field_trigger_target( + &mut self, + id: DocId, + name: &str, + target: Option, + widget: usize, + trigger: pdfcraft_forms::Trigger, + ) -> Result { + use pdfcraft_forms::Trigger; + if self.js_off { + return Ok(false); + } + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.info.xfa.is_some() { + return Ok(false); + } + let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Ok(false) }; + let field = doc + .form + .iter() + .find(|f| f.name == name && target.is_none_or(|t| field_object_id(f.obj) == t)) + .ok_or_else(|| EditError::Invalid(format!("there is no field named {name:?}")))?; + let Some(w) = field.widgets.get(widget) else { return Ok(false) }; + let scripts = pdfcraft_forms::widget_scripts(cos, w.obj, trigger); + if scripts.is_empty() { + return Ok(false); + } + let target_id = Some(field_object_id(field.obj)); + let event = match trigger { + Trigger::OnFocus => "Focus", + Trigger::OnBlur => "Blur", + other => other.label(), + }; + let before = doc.generation; + let mut out = JsOutput::default(); + // Each action of a /Next chain runs on its own, as Acrobat runs them: one that fails + // doesn't stop the next. + for script in scripts { + let o = match self.run_javascript_job(id, &script, (Some(name), target_id), &[], Some(event), false) { + Ok(o) => o, + Err(e) => { + out.errors.push(e.to_string()); + continue; + } + }; + if matches!(trigger, Trigger::MouseUp | Trigger::MouseDown) { + out.append(JsOutput { + alerts: o.alerts, + console: o.console, + requests: o.requests, + errors: o.error.into_iter().chain(o.doc_errors.iter().cloned()).collect(), + doc_errors: o.doc_errors, + }); + } else { + out.absorb(&o); + } + } + let doc = self.doc_mut(id)?; + doc.js_output.append(out); + Ok(doc.generation != before) + } + /// Run signature field `field`'s XFA signing scripts for `event` in document `id`: `preSign` /// before it is signed, `postSign` after, as one undoable step, as a form's signing events run /// (DA 31's dates the signature and locks the date). `Ok(false)`, with nothing changed, when @@ -974,9 +1094,66 @@ impl Session { self.run_javascript_job(id, "", (None, None), &[], None, true) } + /// Opening document `id`: its document-level scripts run once, then the catalog + /// `/OpenAction`'s JavaScript, in the document's retained realm (#269). Not on an XFA form + /// (its own opening scripts run instead) or with JavaScript off. What they showed or asked + /// for waits in [`Session::take_js_output`]. + pub(crate) fn run_open_scripts(&mut self, id: DocId) { + if self.js_off { + return; + } + let Some(doc) = self.get(id).filter(|d| d.info.xfa.is_none()) else { return }; + let has_document_scripts = !doc.document_scripts().is_empty(); + let opens = + doc.editor.as_ref().map(|e| pdfcraft_forms::document_action_scripts(&e.cos, pdfcraft_forms::DocumentTrigger::Open)).unwrap_or_default(); + if !has_document_scripts && opens.is_empty() { + return; + } + let mut out = JsOutput::default(); + if has_document_scripts && opens.is_empty() { + match self.run_opening_javascript(id) { + Ok(o) => out.absorb(&o), + Err(e) => out.errors.push(e.to_string()), + } + } + // The first open action initializes the realm with the document scripts too. + out.append(self.run_actions(id, DocumentScriptEvent::CatalogOpen, &opens)); + unasked_requests(&mut out.requests); + if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) { + doc.js_output.append(out); + } + } + + /// Run the catalog's JavaScript actions for document event `event` (`/AA /WS` before a save, + /// `/AA /WP` before printing, `/OpenAction` on opening) in document `id`'s retained realm. + /// Each action's field changes are one undoable edit. A failing action is reported in the + /// output and the next one still runs; it never stops the save or print, which must not + /// lose the user's work. Requests are limited to what an unasked script may make. With + /// JavaScript off, on an XFA form, or with no such action, nothing runs. + pub fn run_document_actions(&mut self, id: DocId, event: DocumentScriptEvent) -> JsOutput { + if self.js_off { + return JsOutput::default(); + } + let Some(doc) = self.get(id).filter(|d| d.info.xfa.is_none()) else { return JsOutput::default() }; + let scripts = doc.editor.as_ref().map(|e| pdfcraft_forms::document_action_scripts(&e.cos, event.trigger())).unwrap_or_default(); + self.run_actions(id, event, &scripts) + } + + fn run_actions(&mut self, id: DocId, event: DocumentScriptEvent, scripts: &[String]) -> JsOutput { + let mut out = JsOutput::default(); + for script in scripts { + match self.run_javascript_job(id, script, (None, None), &[], Some(event.name()), event == DocumentScriptEvent::CatalogOpen) { + Ok(o) => out.absorb(&o), + Err(e) => out.errors.push(format!("{} script: {e}", event.name())), + } + } + unasked_requests(&mut out.requests); + out + } + /// Run an explicitly dispatched PDF document action in the document's retained runtime. pub fn run_document_script_event(&mut self, id: DocId, event: DocumentScriptEvent, script: &str) -> Result { - self.run_javascript_job(id, script, (None, None), &[], Some(event), false) + self.run_javascript_job(id, script, (None, None), &[], Some(event.name()), false) } /// Route generated XFA clicks to the XFA engine, or run a synchronous AcroForm job and apply its outcome. @@ -986,7 +1163,7 @@ impl Session { script: &str, (target, target_id): (Option<&str>, Option), answers: &[Option], - document_event: Option, + event_name: Option<&'static str>, opening: bool, ) -> Result { if self.js_off { @@ -997,6 +1174,7 @@ impl Session { validate_script_target(target, target_id, &doc.form)?; // A button PdfCraft generated from an XFA template runs its XFA click script. if let Some(t) = target + && event_name.is_none_or(|n| n == "Mouse Up") && let Some(tpl) = doc.xfa_template.clone() && let Some(field) = doc.form.iter().find(|f| f.name == t && target_id.is_none_or(|id| field_object_id(f.obj) == id)) && field.kind == crate::FormFieldKind::PushButton @@ -1020,8 +1198,8 @@ impl Session { let fields = pdfcraft_forms::fields(&cos); let states = std::sync::Arc::new(fields.iter().map(|f| crate::live_scripts::live_state(doc, &cos, f, &runner.doc)).collect::>()); let event = match target { - Some(t) => pdfcraft_js::Event { will_commit: false, target_id, ..pdfcraft_js::Event::field("Mouse Up", t, "") }, - None => pdfcraft_js::Event::doc(document_event.map_or("Console", DocumentScriptEvent::name)), + Some(t) => pdfcraft_js::Event { will_commit: false, target_id, ..pdfcraft_js::Event::field(event_name.unwrap_or("Mouse Up"), t, "") }, + None => pdfcraft_js::Event::doc(event_name.unwrap_or("Console")), }; let calculations = calculation_scripts(&cos, &fields); let runtime = doc.js_runtime.clone(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4b9a3cd7ab..1fb2a6e951 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -94,7 +94,9 @@ pub use pdfcraft_organize::check_custom_key; pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; pub use pdfcraft_cos::{Algorithm, civil_from_days}; -pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; +pub use pdfcraft_create::{ + CONVERTIBLE, CreateError, IMAGE_EXTENSIONS, ImageResolution, OFFICE_EXTENSIONS, SourceKind, TEXT_EXTENSIONS, decode_text, source_kind, +}; pub use pdfcraft_edit::added::{ CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, text_width as added_text_width, @@ -168,7 +170,7 @@ use pictures::{embed_image, fit_picture, mark_source, model_poster}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; -pub use sources::{CombineSource, Combined, combine_source_check, source_page_count}; +pub use sources::{CombineSource, Combined, combine_source_check, office_to_pdf, source_page_count}; use sources::{open_source, open_source_with}; #[cfg(test)] use test_hooks::{ @@ -200,5 +202,7 @@ fn display_config(config: &pdfcraft_render::RenderConfig) -> pdfcraft_render::Re /// How long a custom stamp's text line may be, in characters (#1741). pub const MAX_STAMP_LINE: usize = 200; +#[cfg(test)] +mod document_actions_tests; #[cfg(test)] mod tests; diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index 8897beca49..6ba418deee 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -101,35 +101,7 @@ impl Session { Self::draw_captions(&mut document, self.captions.clone()); } self.docs.push(document); - if !self.js_off && self.get(id).is_some_and(|d| d.info.xfa.is_none() && !d.document_scripts().is_empty()) { - match self.run_opening_javascript(id) { - Ok(mut out) => { - out.requests.retain(|r| { - matches!( - r, - js::Request::Highlight(_) - | js::Request::Zoom(_) - | js::Request::Focus(_) - | js::Request::FocusField { .. } - | js::Request::GoToPage(_) - ) - }); - if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) { - doc.js_output.requests.extend(out.requests); - doc.js_output.alerts.extend(out.alerts); - doc.js_output.console.extend(out.console); - doc.js_output.errors.extend(out.error); - doc.js_output.doc_errors.extend(out.doc_errors.iter().cloned()); - doc.js_output.errors.extend(out.doc_errors); - } - } - Err(e) => { - if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) { - doc.js_output.errors.push(e.to_string()); - } - } - } - } + self.run_open_scripts(id); id } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index bd83242682..187cc217d8 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -13,7 +13,7 @@ use crate::{ BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hairlines, HairlinesFixed, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, StatusCaptions, SummarySort, TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list, - comment_summary, decode_text, display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source, + comment_summary, decode_text, display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, office_to_pdf, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets, diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index cbdf178e9d..2f2774ed22 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -1,9 +1,17 @@ use super::{ Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hairlines, HairlinesFixed, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, - OptimizeReport, SaveOptions, Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, + OptimizeReport, SaveOptions, Session, SourceKind, SplitPart, decode_text, guard, office_to_pdf, open_source, open_source_with, optimize, print, print_layer_states, print_raster, set_default_layer_state, source_kind, writable, write_full, }; +/// Whether a conversion may run LibreOffice for an Office document (seconds, in another +/// process) or must refuse it because the caller is on the frame thread. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Office { + Convert, + Refuse, +} + impl Session { /// The page count of another PDF (Replace Pages, Insert Pages dialogs). pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { @@ -41,30 +49,63 @@ impl Session { self.write_new(&doc) } - /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked - /// (it must open and allow copying pages) and returned as it is. + /// Convert a file Create understands (an image, plain text or an Office document) to PDF + /// bytes; a PDF is checked (it must open and allow copying pages) and returned as it is. + /// An Office document runs LibreOffice ([`office_to_pdf`]), which takes seconds: keep it off + /// the frame thread ([`Self::convert_to_pdf_inline`] there). pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + self.convert(name, bytes, Office::Convert) + } + + /// [`Self::convert_to_pdf`] for a caller on the frame thread, which must not wait seconds for + /// LibreOffice: an Office document is refused, pointing to File ▸ Open, which converts it in + /// the background. + pub fn convert_to_pdf_inline(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + self.convert(name, bytes, Office::Refuse) + } + + fn convert(&self, name: &str, bytes: &Arc>, office: Office) -> Result<(SourceKind, Arc>), EditError> { let Some(kind) = source_kind(name, bytes) else { - return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); + return Err(EditError::Source(format!( + "{name}: this file type can't be converted; use a PDF, an image, a .txt file or a Word, Excel or PowerPoint file" + ))); }; + if kind == SourceKind::Office && office == Office::Refuse { + return Err(EditError::Source(format!( + "{name}: Word, Excel and PowerPoint files can't be added here yet; open it with File ▸ Open, which converts it to a PDF" + ))); + } let title = name.rsplit_once('.').map_or(name, |(s, _)| s); // Image decoders read untrusted bytes: a panic in one must not take the app down. let created = guard(|| match kind { SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), + SourceKind::Office => office_to_pdf(name, bytes), }) .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; Ok((kind, created?)) } - /// A file for Insert Pages into `dest`, as PDF bytes and its page count: an image or text - /// file is converted, and a PDF is checked as the insert itself checks it, so a file under - /// `dest`'s own protection that withholds only page extraction is accepted, as pasted pages - /// are (#5081). + /// A file for Insert Pages into `dest`, as PDF bytes and its page count: an image, text or + /// Office file is converted, and a PDF is checked as the insert itself checks it, so a file + /// under `dest`'s own protection that withholds only page extraction is accepted, as pasted + /// pages are (#5081). An Office document runs LibreOffice: keep it off the frame thread + /// ([`Self::insert_source_inline`] there). pub fn insert_source(&self, dest: DocId, name: &str, bytes: &Arc>) -> Result<(usize, Arc>), EditError> { + self.insert_source_with(dest, name, bytes, Office::Convert) + } + + /// [`Self::insert_source`] on the frame thread: Office documents are refused, as + /// [`Self::convert_to_pdf_inline`] refuses them. PDFs are still checked as the insert itself + /// checks them (#5081). + pub fn insert_source_inline(&self, dest: DocId, name: &str, bytes: &Arc>) -> Result<(usize, Arc>), EditError> { + self.insert_source_with(dest, name, bytes, Office::Refuse) + } + + fn insert_source_with(&self, dest: DocId, name: &str, bytes: &Arc>, office: Office) -> Result<(usize, Arc>), EditError> { if !matches!(source_kind(name, bytes), Some(SourceKind::Pdf)) { - let (_, pdf) = self.convert_to_pdf(name, bytes)?; + let (_, pdf) = self.convert(name, bytes, office)?; return Ok((self.page_count_of(name, &pdf)?, pdf)); } let src = crate::sources::open_source_into(name, bytes, self.cos(dest)?)?; @@ -78,16 +119,26 @@ impl Session { Ok(pdfcraft_organize::page_count(&crate::sources::open_source_into(name, bytes, self.cos(dest)?)?)?) } - /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as - /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf - /// without a `%PDF-` header, which the reader may still open. + /// A file for Combine as PDF bytes: an image, text or Office file is converted; a PDF is + /// returned as it is, unchecked (Combine reads it itself, with its password), as is a file + /// named .pdf without a `%PDF-` header, which the reader may still open. pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + self.as_pdf_with(name, bytes, Office::Convert) + } + + /// [`Self::as_pdf`] on the frame thread: Office documents are refused, as + /// [`Self::convert_to_pdf_inline`] refuses them. + pub fn as_pdf_inline(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + self.as_pdf_with(name, bytes, Office::Refuse) + } + + fn as_pdf_with(&self, name: &str, bytes: &Arc>, office: Office) -> Result>, EditError> { let pdf = match source_kind(name, bytes) { Some(SourceKind::Pdf) => true, - Some(SourceKind::Image | SourceKind::Text) => false, + Some(SourceKind::Image | SourceKind::Text | SourceKind::Office) => false, None => name.to_ascii_lowercase().ends_with(".pdf"), }; - if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } + if pdf { Ok(bytes.clone()) } else { self.convert(name, bytes, office).map(|(_, pdf)| pdf) } } /// Reduce File Size: Acrobat's defaults (colour and grey images above 225 ppi to 150 ppi, diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index d8193ba084..ec0033d318 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -132,7 +132,7 @@ impl Session { } /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is - /// cleared, as in Acrobat. + /// cleared, as in Acrobat, and the document's opening scripts run again. pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { self.invalidate_javascript(id); let doc = self.doc_mut(id)?; @@ -156,7 +156,11 @@ impl Session { // The changes whose exits ran are gone with the rest: skip no exit. doc.xfa_exited.clear(); doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + Self::refresh(doc).inspect_err(|_| Self::show_released(doc))?; + // Reverting reopens the saved file, in a fresh realm: its opening scripts run again, + // as they do on opening it (#269). + self.run_open_scripts(id); + Ok(()) } /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open diff --git a/crates/engine/src/sources.rs b/crates/engine/src/sources.rs index e7646e223c..4d1b5ab133 100644 --- a/crates/engine/src/sources.rs +++ b/crates/engine/src/sources.rs @@ -15,6 +15,16 @@ pub struct Combined { pub renamed_fields: Vec<(String, String)>, } +/// Convert the Office document `bytes` named `name` (Word, Excel, PowerPoint, OpenDocument, +/// RTF) to PDF with an installed LibreOffice, and check the result opens. It runs a separate +/// process for seconds, so callers keep it off the frame thread. Desktop only: on the web it +/// returns an error. +pub fn office_to_pdf(name: &str, bytes: &Arc>) -> Result>, EditError> { + let pdf = Arc::new(pdfcraft_create::office_to_pdf(name, bytes)?); + open_source(name, &pdf)?; + Ok(pdf) +} + /// Parse another PDF to copy pages from. pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { open_source_with(name, bytes, None) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7b82a9b8e7..4e24a4f608 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1575,8 +1575,13 @@ fn a_page_with_a_form_field_shown_twice_keeps_one_field() { #[test] fn files_that_cannot_be_converted_are_refused_clearly() { let s = Session::new(); - let err = s.convert_to_pdf("report.docx", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); - assert_eq!(err, EditError::Source("report.docx: this file type can't be converted; use a PDF, an image or a .txt file".into())); + let err = s.convert_to_pdf("archive.zip", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); + assert_eq!( + err, + EditError::Source( + "archive.zip: this file type can't be converted; use a PDF, an image, a .txt file or a Word, Excel or PowerPoint file".into() + ) + ); assert!(matches!(s.convert_to_pdf("empty", &Arc::new(Vec::new())), Err(EditError::Source(_)))); // Damaged inputs of a known type fail with an error, not a panic. assert!(s.convert_to_pdf("bad.png", &Arc::new(b"\x89PNG\r\n\x1a\nnope".to_vec())).is_err()); @@ -1597,8 +1602,8 @@ fn files_for_combine_are_made_into_pdfs_and_pdfs_pass_through() { assert!(Arc::ptr_eq(&s.as_pdf("old.PDF", &headless).unwrap(), &headless)); assert!(s.as_pdf("scan.jpg", &Arc::new(jpeg_bytes())).unwrap().starts_with(b"%PDF-")); assert!(s.as_pdf("notes.txt", &Arc::new(b"hello".to_vec())).unwrap().starts_with(b"%PDF-")); - let err = s.as_pdf("report.docx", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); - assert!(matches!(err, EditError::Source(m) if m.starts_with("report.docx: this file type can't be converted"))); + let err = s.as_pdf("archive.zip", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); + assert!(matches!(err, EditError::Source(m) if m.starts_with("archive.zip: this file type can't be converted"))); } fn protected(user: &str, owner: &str, permissions: i32) -> Arc> { @@ -15359,3 +15364,48 @@ fn calculate_now_simple_calculation_reads_formatted_values_and_groups_as_the_nat assert!(out.console.iter().any(|line| line == "3244.75"), "calculateNow agrees with the native path: {out:?}"); assert_eq!(total(&s), ["3244.75"]); } + +/// A Word document converts through LibreOffice when it is installed; without it, the error +/// says what is missing rather than calling the file a broken PDF. +#[test] +fn office_documents_convert_through_libreoffice_or_explain_why_not() { + let mut s = Session::new(); + let text = s.create_from_text("t", "Quarterly report").unwrap(); + let id = s.open("t.pdf", None, text, None).unwrap(); + let docx = Arc::new(s.get(id).unwrap().export_office(compare::OfficeFormat::Docx)); + assert_eq!(source_kind("Report.docx", &docx), Some(SourceKind::Office)); + match s.convert_to_pdf("Report.docx", &docx) { + Ok((kind, pdf)) => { + assert_eq!(kind, SourceKind::Office); + let converted = s.open_new("Report.pdf", pdf).unwrap(); + assert!(page_texts(&s, converted).concat().contains("Quarterly report")); + } + // Only "not installed" is an acceptable failure: a conversion that fails, times out or + // makes no PDF must fail this test. + Err(e) => assert!(e.to_string().contains("LibreOffice, which isn't installed"), "{e}"), + } + // Combine treats it as a file to convert, not as a PDF to pass through. + assert!( + s.as_pdf("Report.docx", &docx).map_or_else(|e| e.to_string().contains("LibreOffice, which isn't installed"), |pdf| pdf.starts_with(b"%PDF-")) + ); +} + +/// The frame thread never waits for LibreOffice: Insert, Combine and Create Multiple refuse an +/// Office document and point to File ▸ Open, while images and text still convert there. +#[test] +fn inline_conversions_refuse_office_documents_without_running_libreoffice() { + let s = Session::new(); + let docx = Arc::new(b"PK\x03\x04\x14\x00".to_vec()); + for err in + [s.convert_to_pdf_inline("Report.docx", &docx).map(|_| ()).unwrap_err(), s.as_pdf_inline("Report.docx", &docx).map(|_| ()).unwrap_err()] + { + assert_eq!( + err, + EditError::Source( + "Report.docx: Word, Excel and PowerPoint files can't be added here yet; open it with File ▸ Open, which converts it to a PDF".into() + ) + ); + } + assert_eq!(s.convert_to_pdf_inline("notes.txt", &Arc::new(b"hello".to_vec())).unwrap().0, SourceKind::Text); + assert!(s.as_pdf_inline("scan.jpg", &Arc::new(jpeg_bytes())).unwrap().starts_with(b"%PDF-")); +} diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 6a91220713..96d03e1869 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -128,6 +128,21 @@ fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mu }) } +/// The JavaScript widget annotation `widget` runs for `trigger` (ISO 32000-2 Table 197: `/A` +/// or `/AA /U` for Mouse Up, the other `/AA` entries for the rest): that action's, when it is a +/// JavaScript action, then those of the actions chained after it with `/Next`, in the order +/// they run (bounded as [`crate::document_action_scripts`] is). Reads only the widget's own +/// dictionary, so a pointer moving over a large form costs nothing per field. +pub fn widget_scripts(doc: &Document, widget: pdfcraft_cos::ObjRef, trigger: Trigger) -> Vec { + let w = doc.get(widget); + let Some(wd) = w.as_dict() else { return Vec::new() }; + let action = match trigger.key() { + None => wd.get(b"A").cloned().or_else(|| wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict()?.get(b"U").cloned())), + Some(k) => wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict()?.get(k).cloned()), + }; + crate::scripting::chained_scripts(doc, action) +} + /// The actions of field `name` (its first widget), by trigger, in the tab's order. pub fn field_actions(doc: &Document, name: &str) -> Result, FormError> { let all = fields(doc); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index fc4bf266b5..96d6ea2a6e 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -24,15 +24,15 @@ mod calc_order; pub mod detect; mod scripting; pub mod table; -pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions}; +pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions, widget_scripts}; pub use author::{ BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, delete_field_object, delete_widget, duplicate_field, look, redraw_field, redraw_fields, set_button_icon, set_button_icon_target, set_props, }; pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order}; pub use scripting::{ - FieldChange, FieldEvent, NoScripts, PageEvent, PageScript, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, - page_scripts, set_document_script, set_field_script, + DocumentTrigger, FieldChange, FieldEvent, NoScripts, PageEvent, PageScript, ScriptResult, Scripts, apply_script_changes, document_action_scripts, + document_scripts, document_scripts_named, page_scripts, set_document_script, set_field_script, }; mod appearances; diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index f6546db546..453eb477be 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -57,6 +57,11 @@ pub struct FieldChange { pub border_style: Option, pub user_name: Option, pub icon: Option>, + /// A combo box's or list box's new items (export value, text), from `setItems`. + pub options: Option>, + /// JavaScript actions the script installed with `setAction`, by trigger name (`MouseUp`, + /// `OnFocus`, `Keystroke`, …), each replacing what the field ran for that trigger. + pub actions: Vec<(String, String)>, } /// What a script did. @@ -171,6 +176,73 @@ fn protect_other_mk_owners(doc: &mut Document, field: &Field, owners: &std::coll Ok(()) } +/// Most items a script may give a choice field ([`FieldChange::options`]). +const MAX_SCRIPT_OPTIONS: usize = 10_000; + +/// Write a combo box's or list box's items (export value, text) as its `/Opt`: a plain text +/// where the two are the same, a pair where they differ (ISO 32000-2 Table 231). The selected +/// indices (`/I`) no longer apply and go; the value is written after, and the field redrawn. +fn write_options(doc: &mut Document, f: &Field, options: &[(String, String)]) -> Result<(), FormError> { + if !matches!(f.kind, FieldKind::Combo | FieldKind::List) { + return Err(FormError::Invalid(format!("{} is not a combo box or list box: it has no items to set", f.name))); + } + if options.len() > MAX_SCRIPT_OPTIONS { + return Err(FormError::Invalid(format!("a script may give a field at most {MAX_SCRIPT_OPTIONS} items"))); + } + let opt = options + .iter() + .map(|(export, text)| { + if export == text { + Object::String(PdfString::text(text)) + } else { + Object::Array(vec![Object::String(PdfString::text(export)), Object::String(PdfString::text(text))]) + } + }) + .collect(); + doc.update_dict(f.obj, |d| { + d.set(b"Opt".to_vec(), Object::Array(opt)); + d.remove(b"I"); + })?; + crate::author::redraw_field_object(doc, &f.name, f.obj) +} + +/// Install `script` as field `f`'s JavaScript for `trigger` (a `setAction` trigger name), +/// replacing the action it had there: the field's `/AA` for Keystroke, Validate, Calculate and +/// Format, each widget's `/A` (Mouse Up) or `/AA` for the rest. +fn set_action(doc: &mut Document, f: &Field, trigger: &str, script: &str) -> Result<(), FormError> { + let event = match trigger { + "Keystroke" => Some("keystroke"), + "Validate" => Some("validate"), + "Calculate" => Some("calculate"), + "Format" => Some("format"), + "MouseUp" => Some("mouse_up"), + _ => None, + }; + if let Some(event) = event { + // The field the script's object stands for, even when another shares its name. + return set_script_of(doc, f, event, Some(script)); + } + let key: &[u8] = match trigger { + "MouseDown" => b"D", + "MouseEnter" => b"E", + "MouseExit" => b"X", + "OnFocus" => b"Fo", + "OnBlur" => b"Bl", + other => return Err(FormError::Invalid(format!("unknown action trigger {other:?}"))), + }; + let mut action = pdfcraft_cos::Dict::new(); + action.set(b"S".to_vec(), Object::name("JavaScript")); + action.set(b"JS".to_vec(), PdfString::text(script)); + let action = doc.add(Object::Dict(action)); + for w in &f.widgets { + let mut aa = + doc.get(w.obj).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + aa.set(key.to_vec(), Object::Ref(action)); + doc.update_dict(w.obj, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?; + } + Ok(()) +} + /// [`formatted`] with dates in English names, for widgets whose fonts can't draw the date /// language's; `None` when the field has no Format script or the English run didn't happen. pub(crate) fn formatted_in_english(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option { @@ -211,6 +283,19 @@ pub(crate) fn apply_changes( for c in changes { let Some(f) = all.iter().find(|f| f.name == c.name && c.object.is_none_or(|obj| f.obj == obj)) else { continue }; + // New items go first, so a value among them is written against them. + let reread; + let f = match &c.options { + Some(options) => { + write_options(doc, f, options)?; + reread = fields(doc).into_iter().find(|x| x.obj == f.obj).unwrap_or_else(|| f.clone()); + &reread + } + None => f, + }; + for (trigger, script) in &c.actions { + set_action(doc, f, trigger, script)?; + } if let Some(v) = &c.value && !except_object.map_or(c.name == except, |obj| f.obj == obj) && *v != f.value @@ -454,6 +539,79 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { out } +/// A document-level action trigger whose JavaScript the viewer runs (ISO 32000-2 §12.6.3, +/// Table 199): the catalog's `/OpenAction`, or its `/AA` entries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DocumentTrigger { + /// The catalog `/OpenAction`, once the document has opened. + Open, + /// `/AA /WS`, before the document is saved. + WillSave, + /// `/AA /WP`, before the document is printed. + WillPrint, +} + +/// Most actions read for one document trigger, along its `/Next` chain. A chain can be cyclic +/// or arbitrarily long; the rest are ignored. +const MAX_CHAINED_ACTIONS: usize = 256; + +/// The JavaScript of the catalog actions for `trigger`, in the order a viewer runs them: the +/// action, then its `/Next` actions depth first (§12.6.2). Actions of other types (a `/GoTo` +/// open action, say) are skipped; their `/Next` chains are still followed. Bounded: each object +/// is read once and at most [`MAX_CHAINED_ACTIONS`] actions are visited. +pub fn document_action_scripts(doc: &Document, trigger: DocumentTrigger) -> Vec { + let Some(root) = doc.root() else { return Vec::new() }; + let cat = doc.get(root); + let Some(cat) = cat.as_dict() else { return Vec::new() }; + let first = match trigger { + DocumentTrigger::Open => cat.get(b"OpenAction").cloned(), + DocumentTrigger::WillSave | DocumentTrigger::WillPrint => { + let key: &[u8] = if trigger == DocumentTrigger::WillSave { b"WS" } else { b"WP" }; + cat.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|d| d.get(key)).cloned()) + } + }; + chained_scripts(doc, first) +} + +/// The JavaScript of action `first` and of the actions chained after it, in the order they run: +/// the action, then its `/Next` actions depth first (ISO 32000-2 §12.6.2). Actions of other +/// types are skipped; their `/Next` chains are still followed. Bounded: each object is read +/// once and at most [`MAX_CHAINED_ACTIONS`] actions are visited. +pub(crate) fn chained_scripts(doc: &Document, first: Option) -> Vec { + let mut budget = crate::script_budget(); + let mut seen: HashSet = HashSet::new(); + let mut pending: Vec = first.into_iter().collect(); + let mut visited = 0usize; + let mut out = Vec::new(); + while let Some(raw) = pending.pop() { + visited += 1; + if visited > MAX_CHAINED_ACTIONS { + break; + } + if let Some(r) = raw.as_ref() + && !seen.insert(r.num) + { + continue; + } + let action = doc.resolve(&raw); + if let Some(list) = action.as_array() { + // Pushed in reverse so they run in the order listed. + pending.extend(list.iter().take(MAX_CHAINED_ACTIONS).rev().cloned()); + continue; + } + let Some(d) = action.as_dict() else { continue }; + if d.name(b"S") == Some(b"JavaScript".as_slice()) + && let Some(js) = crate::script(doc, &raw, &mut budget) + { + out.push(js); + } + if let Some(next) = d.get(b"Next") { + pending.push(next.clone()); + } + } + out +} + /// Call `f` on the array `o` is, or refers to, read in place: resolving a direct array would /// copy it. fn in_array(doc: &Document, o: &Object, f: impl FnOnce(&[Object])) { @@ -578,7 +736,12 @@ pub fn set_document_script(doc: &mut Document, name: &str, js: Option<&str>) -> /// (its widgets' `/A`). Calculated fields join the calculation order (`/CO`). pub fn set_field_script(doc: &mut Document, name: &str, event: &str, js: Option<&str>) -> Result<(), FormError> { let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + set_script_of(doc, f, event, js) +} + +/// [`set_field_script`] for the field `f` itself, which may share its name with another. +fn set_script_of(doc: &mut Document, f: &Field, event: &str, js: Option<&str>) -> Result<(), FormError> { let action = |doc: &mut Document, js: &str| { let mut a = pdfcraft_cos::Dict::new(); a.set(b"S".to_vec(), Object::name("JavaScript")); diff --git a/crates/js/src/document.rs b/crates/js/src/document.rs index 3bcd371419..f13c9434e9 100644 --- a/crates/js/src/document.rs +++ b/crates/js/src/document.rs @@ -279,7 +279,71 @@ fn field_object_new(ctx: &mut Context, name: &str, id: Option, token: u64) Ok(JsValue::undefined()) } + /// `setAction(cTrigger, cScript)`: install `cScript` as the field's JavaScript for + /// `cTrigger`, replacing what it ran before; saved with the document (#269). + fn set_action(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let trigger = text(&arg(args, 0), c)?; + if !SET_ACTION_TRIGGERS.contains(&trigger.as_str()) { + return Err(error(format!("setAction: unknown trigger {trigger:?}; use one of {}", SET_ACTION_TRIGGERS.join(", ")))); + } + let script = text(&arg(args, 1), c)?; + if let Some(why) = crate::run::refuse(&script) { + return Err(error(format!("setAction: {why}"))); + } + write(c, name, |f| { + f.actions.retain(|(t, _)| *t != trigger); + f.actions.push((trigger, script)); + })?; + Ok(JsValue::undefined()) + } + /// `setItems(oArray)`: replace a combo box's or list box's items. Each item is its text, or + /// a `[text, exportValue]` pair. The value stays when an item still has it, and otherwise + /// becomes the first item's (#269). + fn set_items(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult { + let f = read(c, name)?; + if !matches!(f.kind, FieldType::ComboBox | FieldType::ListBox) { + return Err(error(format!("setItems: {} is not a combo box or list box", f.name))); + } + let list = arg(args, 0).as_object().filter(JsObject::is_array).ok_or_else(|| error("setItems needs an array of items"))?; + let list = JsArray::from_object(list)?; + let len = list.length(c)?; + if len > MAX_ITEMS as u64 { + return Err(error(format!("setItems: at most {MAX_ITEMS} items"))); + } + let mut items = Vec::new(); + let mut bytes = 0usize; + for i in 0..len { + let item = list.get(i, c)?; + let (label, export) = match item.as_object().filter(JsObject::is_array) { + Some(pair) => { + let pair = JsArray::from_object(pair.clone())?; + let label = text(&pair.get(0, c)?, c)?; + let export = pair.get(1, c)?; + let export = if export.is_undefined() { label.clone() } else { text(&export, c)? }; + (label, export) + } + None => { + let label = text(&item, c)?; + (label.clone(), label) + } + }; + bytes = bytes.saturating_add(label.len()).saturating_add(export.len()); + if bytes > MAX_ITEM_BYTES { + return Err(error("setItems: the items are too long")); + } + items.push((export, label)); + } + write(c, name, |f| { + let kept: Vec = f.value.iter().filter(|v| items.iter().any(|(export, _)| export == *v)).cloned().collect(); + f.value = if kept.is_empty() { items.first().map(|(export, _)| vec![export.clone()]).unwrap_or_default() } else { kept }; + f.options = items; + })?; + Ok(JsValue::undefined()) + } + let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE; + let set_action = NativeFunction::from_copy_closure_with_captures(set_action, n.clone()); + let set_items = NativeFunction::from_copy_closure_with_captures(set_items, n.clone()); let get_item_at = NativeFunction::from_copy_closure_with_captures(get_item_at, n.clone()); let is_box_checked = NativeFunction::from_copy_closure_with_captures(is_box_checked, n.clone()); let check_this_box = NativeFunction::from_copy_closure_with_captures(check_this_box, n.clone()); @@ -314,6 +378,8 @@ fn field_object_new(ctx: &mut Context, name: &str, id: Option, token: u64) .function(is_box_checked, js_string!("isBoxChecked"), 1) .function(check_this_box, js_string!("checkThisBox"), 2) .function(set_focus, js_string!("setFocus"), 0) + .function(set_action, js_string!("setAction"), 2) + .function(set_items, js_string!("setItems"), 1) .function(button_get_icon, js_string!("buttonGetIcon"), 1) .function(button_set_icon, js_string!("buttonSetIcon"), 2) .function(button_get_caption, js_string!("buttonGetCaption"), 1) @@ -321,6 +387,14 @@ fn field_object_new(ctx: &mut Context, name: &str, id: Option, token: u64) .build() } +/// The triggers `field.setAction` takes (Acrobat JavaScript reference, Field.setAction). +const SET_ACTION_TRIGGERS: [&str; 10] = + ["MouseUp", "MouseDown", "MouseEnter", "MouseExit", "OnFocus", "OnBlur", "Keystroke", "Validate", "Calculate", "Format"]; +/// Most items one `setItems` call takes, and most bytes of their texts and export values: a +/// script must not grow a field without bound. +const MAX_ITEMS: usize = 10_000; +const MAX_ITEM_BYTES: usize = 1 << 20; + // ── the document (global object) ──────────────────────────────────────────────────────────── pub(crate) fn get_field(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 941b70d1f3..81624cfca0 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -587,6 +587,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { ctx.register_global_property(js_string!("display"), display, rw)?; let color = color_object(ctx); ctx.register_global_property(js_string!("color"), color, rw)?; + crate::source::install(ctx)?; Ok(()) } diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 59be3c7db4..c5e0d6b391 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -44,6 +44,7 @@ mod host; mod model; mod nesting; mod run; +mod source; #[cfg(test)] pub(crate) use format::PRINTD_SCANNED; diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index bd2f025786..f9acb2634f 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -74,6 +74,11 @@ pub struct FieldState { pub caption_mixed: bool, pub icon_mixed: bool, pub icon_written: bool, + /// JavaScript actions a script installed with `setAction` during this run, by trigger + /// (`MouseUp`, `MouseDown`, `MouseEnter`, `MouseExit`, `OnFocus`, `OnBlur`, `Keystroke`, + /// `Validate`, `Calculate`, `Format`), the last one for each trigger. Empty in a snapshot: + /// the host reads the document's own actions from the PDF. + pub actions: Vec<(String, String)>, } impl FieldState { @@ -108,6 +113,7 @@ impl FieldState { caption_mixed: false, icon_mixed: false, icon_written: false, + actions: Vec::new(), } } diff --git a/crates/js/src/source.rs b/crates/js/src/source.rs new file mode 100644 index 0000000000..deafcb8f18 --- /dev/null +++ b/crates/js/src/source.rs @@ -0,0 +1,145 @@ +//! `toSource()` on objects and arrays, which older forms use to keep state in a field or a +//! global as text (#269, ported from #344). It writes a JavaScript literal of the value; scripts +//! here can't `eval` it back, since strings never compile. Plain data only: strings, numbers, +//! booleans, null, undefined, arrays and objects of them. Functions and cycles are refused, and +//! so is a value past the depth, size or length bounds, rather than writing something that +//! reads as another value. + +use super::host::{error, function, s, text}; +use super::{Context, JsArray, JsObject, JsResult, JsValue, NativeFunction, js_string}; +use boa_engine::property::{PropertyDescriptor, PropertyKey}; + +/// Deepest nesting written. +const MAX_DEPTH: usize = 64; +/// Most values (each array item, property and the value itself) written. +const MAX_VALUES: usize = 10_000; +/// Longest source written, in bytes. +const MAX_BYTES: usize = 1 << 20; + +/// Install `toSource` on `Object.prototype` (arrays inherit it), not enumerable, as in the +/// engines that had it. +pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { + let to_source = function(ctx, NativeFunction::from_fn_ptr(to_source)); + let prototype = ctx.intrinsics().constructors().object().prototype(); + prototype + .insert_property(js_string!("toSource"), PropertyDescriptor::builder().value(to_source).writable(true).configurable(true).enumerable(false)); + Ok(()) +} + +fn to_source(this: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + let mut writer = Writer { values: MAX_VALUES, bytes: MAX_BYTES, open: Vec::new() }; + let out = writer.value(this, ctx)?; + Ok(s(&out)) +} + +/// What is left of the bounds, and the objects being written (to refuse a cycle). +struct Writer { + values: usize, + bytes: usize, + open: Vec, +} + +impl Writer { + fn value(&mut self, v: &JsValue, ctx: &mut Context) -> JsResult { + self.values = self.values.checked_sub(1).ok_or_else(|| error("toSource: the value has too many parts"))?; + let out = if v.is_callable() { + return Err(error("toSource: functions can't be written")); + } else if let Some(o) = v.as_object() { + self.object(&o, ctx)? + } else if let Some(text) = v.as_string() { + quote(&text.to_std_string_escaped()) + } else if v.is_null() { + "null".into() + } else if v.is_undefined() { + "undefined".into() + } else { + text(v, ctx)? + }; + self.charge(out.len())?; + Ok(out) + } + + fn object(&mut self, o: &JsObject, ctx: &mut Context) -> JsResult { + if self.open.iter().any(|open| open == o) { + return Err(error("toSource: the value refers to itself")); + } + if self.open.len() >= MAX_DEPTH { + return Err(error("toSource: the value is nested too deeply")); + } + if !o.is_array() && !plain(o, ctx) { + return Err(error("toSource: only plain objects, arrays, strings, numbers, booleans, null and undefined can be written")); + } + self.open.push(o.clone()); + let out = if o.is_array() { self.array(o, ctx) } else { self.fields(o, ctx) }; + self.open.pop(); + out + } + + fn array(&mut self, o: &JsObject, ctx: &mut Context) -> JsResult { + let array = JsArray::from_object(o.clone())?; + let len = array.length(ctx)?; + if len > self.values as u64 { + return Err(error("toSource: the array is too long")); + } + let mut items = Vec::new(); + for i in 0..len { + items.push(self.value(&array.get(i, ctx)?, ctx)?); + } + Ok(format!("[{}]", items.join(", "))) + } + + fn fields(&mut self, o: &JsObject, ctx: &mut Context) -> JsResult { + let mut items = Vec::new(); + for key in o.own_property_keys(ctx)? { + let label = match &key { + PropertyKey::Symbol(_) => continue, + PropertyKey::String(k) => k.to_std_string_escaped(), + PropertyKey::Index(i) => i.get().to_string(), + }; + // `"__proto__": v` in a literal sets the prototype rather than an own property. + if label == "__proto__" { + return Err(error("toSource: a property named __proto__ can't be written")); + } + self.charge(label.len())?; + let value = self.value(&o.get(key, ctx)?, ctx)?; + items.push(format!("{}:{value}", quote(&label))); + } + Ok(format!("({{{}}})", items.join(", "))) + } + + /// Count `n` bytes against the length bound. Every part counts, including the parts of what + /// is joined later, so a value repeated many times can't build a long result first. + fn charge(&mut self, n: usize) -> JsResult<()> { + self.bytes = self.bytes.checked_sub(n).ok_or_else(|| error("toSource: the result would be too long"))?; + Ok(()) + } +} + +/// Whether `o` is a plain object: an ordinary one whose prototype is `Object.prototype` (or +/// none), so its own properties are all there is to it. A Date, a RegExp, a boxed value, a +/// proxy or a class instance holds more than a literal of its properties would give back. +fn plain(o: &JsObject, ctx: &Context) -> bool { + let object_prototype = ctx.intrinsics().constructors().object().prototype(); + o.is_ordinary() && o.prototype().is_none_or(|p| p == object_prototype) +} + +/// `text` as a double-quoted JavaScript string literal. +fn quote(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 2); + out.push('"'); + for c in text.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{2028}' => out.push_str("\\u2028"), + '\u{2029}' => out.push_str("\\u2029"), + c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index a501f463ee..224fc09283 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -2726,3 +2726,76 @@ fn get_ocgs_lists_layers_and_reports_the_ones_a_script_turns() { let none = run("this.getOCGs() === null;", &Event::doc("Console"), &doc(), &[], &[], Default::default()); assert_eq!(none.result.as_deref(), Some("true")); } + +/// `toSource` writes plain state as a JavaScript literal of the same value (#269, from #344). +/// Scripts here can't `eval` it back (strings never compile), so the exact text is checked. +#[test] +fn to_source_writes_plain_state_as_a_literal() { + let event = Event::doc("Console"); + let out = go( + "var v = {tab: 2, name: 'a \"q\"\\n', on: true, none: null, list: ['qty', null, undefined, 1.5, [3]]}; \ + [v.toSource(), [1, 'x'].toSource(), ({}).toSource()].join('|');", + &event, + ); + assert_eq!(out.error, None, "{out:?}"); + assert_eq!( + out.result.as_deref(), + Some(r#"({"tab":2, "name":"a \"q\"\n", "on":true, "none":null, "list":["qty", null, undefined, 1.5, [3]]})|[1, "x"]|({})"#) + ); + assert!(go("Object.keys({}).indexOf('toSource') < 0 && !({}).propertyIsEnumerable('toSource');", &event).result.as_deref() == Some("true")); +} + +/// `toSource` refuses what it can't write faithfully or within its bounds, with an error the +/// script can catch. +#[test] +fn to_source_refuses_cycles_functions_and_oversized_state() { + let event = Event::doc("Console"); + for (script, expected) in [ + ("var v = {}; v.v = v; v.toSource();", "refers to itself"), + ("({f: function () {}}).toSource();", "functions"), + ("var v = []; v.length = 1000000; v.toSource();", "too long"), + ("var v = []; for (var i = 0; i < 100; i++) v = [v]; v.toSource();", "nested too deeply"), + ("var s = new Array(4097).join('x'); var v = []; for (var i = 0; i < 300; i++) v.push(s); v.toSource();", "too long"), + ("({when: new Date(0)}).toSource();", "only plain objects"), + ("[/x/].toSource();", "only plain objects"), + ("function C() { this.a = 1; } new C().toSource();", "only plain objects"), + ("var v = JSON.parse('{\"__proto__\": 1}'); v.toSource();", "__proto__"), + ] { + let out = go(script, &event); + assert!(out.error.as_deref().is_some_and(|e| e.contains(expected)), "{script}: {out:?}"); + } + let out = go("try { var v = {}; v.v = v; v.toSource(); 'no'; } catch (e) { 'caught'; }", &event); + assert_eq!(out.result.as_deref(), Some("caught")); +} + +/// `setItems` replaces a choice field's items (text, or [text, export] pairs), keeping the value +/// when an item still has it; `setAction` records the script for its trigger, the last one +/// winning. Both refuse what they can't do. +#[test] +fn set_items_and_set_action_change_the_field() { + let event = Event::doc("Console"); + let out = go( + "var f = getField('size'); f.setItems([['Medium', 'm'], 'Huge', ['Tiny']]); \ + f.setAction('OnFocus', 'console.println(1)'); f.setAction('OnFocus', 'console.println(2)'); f.setAction('Keystroke', 'event.rc = true;'); \ + [f.numItems, f.getItemAt(1, true), f.getItemAt(2, false), f.value].join('|');", + &event, + ); + assert_eq!(out.error, None, "{out:?}"); + assert_eq!(out.result.as_deref(), Some("3|Huge|Tiny|m")); + let size = out.changed.iter().find(|f| f.name == "size").unwrap(); + assert_eq!(size.options, [("m".into(), "Medium".into()), ("Huge".into(), "Huge".into()), ("Tiny".into(), "Tiny".into())]); + assert_eq!(size.actions, [("OnFocus".into(), "console.println(2)".into()), ("Keystroke".into(), "event.rc = true;".into())]); + + let out = go("var f = getField('size'); f.setItems(['a', 'b']); f.value;", &event); + assert_eq!(out.result.as_deref(), Some("a"), "the old value is gone: the first item's"); + + for (script, expected) in [ + ("getField('price').setItems(['a']);", "not a combo box or list box"), + ("getField('size').setItems('a');", "needs an array"), + ("var a = []; a.length = 20000; getField('size').setItems(a);", "at most"), + ("getField('size').setAction('Explode', 'x');", "unknown trigger"), + ] { + let out = go(script, &event); + assert!(out.error.as_deref().is_some_and(|e| e.contains(expected)), "{script}: {out:?}"); + } +} diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 8c96de4529..81c65a2b03 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -257,6 +257,14 @@ impl PdfCraftApp { Some(Err(error)) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]), None => {} }, + open_worker::ResultKind::Converted(created) => match self.open_converted_pdf(&pending.name, created) { + Ok(()) => { + if let Some(command) = pending.then { + self.execute(command); + } + } + Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]), + }, open_worker::ResultKind::Prepared { bytes, result } => match *result { Ok(prepared) => match self.finish_open(&pending.name, pending.path.clone(), bytes.len(), prepared, pending.activate) { Ok(id) => { @@ -1009,24 +1017,29 @@ impl PdfCraftApp { self.open_path_request(path, None, Some((layout, continuous))); } - /// The File ▸ Open picker: PDFs, and images and text that become PDFs. - #[cfg(not(target_arch = "wasm32"))] + /// The File ▸ Open picker's file types, in order; the first, PDF, is the default. Images, text + /// and (on the desktop, through LibreOffice) Office documents open as new, unsaved PDFs. + /// No "All files" in the browser or on macOS, whose panel merges every type into one list + /// rather than offering a choice. + pub(crate) fn open_filters() -> Vec<(String, Vec<&'static str>)> { + let mut filters = vec![("PDF".to_string(), vec!["pdf"]), (tl!("Images").to_string(), pdfcraft_engine::IMAGE_EXTENSIONS.to_vec())]; + #[cfg(not(target_arch = "wasm32"))] + filters.push((tl!("Office documents").to_string(), pdfcraft_engine::OFFICE_EXTENSIONS.to_vec())); + filters.push((tl!("Text").to_string(), pdfcraft_engine::TEXT_EXTENSIONS.to_vec())); + #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))] + filters.push((tl!("All files").to_string(), vec!["*"])); + filters + } + + /// The File ▸ Open picker ([`Self::open_filters`]), each type matched in any letter case. pub(super) fn open_picker() -> rfd::AsyncFileDialog { - rfd::AsyncFileDialog::new() - .add_filter_any_case("PDF", &["pdf"]) - .add_filter_any_case(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE) + Self::open_filters().into_iter().fold(rfd::AsyncFileDialog::new(), |dialog, (name, extensions)| dialog.add_filter_any_case(name, &extensions)) } pub fn open_dialog(&mut self) { self.startup_superseded = true; #[cfg(not(target_arch = "wasm32"))] - self.pick( - pickers::PickFor::Open, - rfd::AsyncFileDialog::new() - .add_filter_any_case("PDF", &["pdf"]) - .add_filter_any_case(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE), - true, - ); + self.pick(pickers::PickFor::Open, Self::open_picker(), true); // Browsers pick files asynchronously; the bytes arrive through `inbox`. #[cfg(target_arch = "wasm32")] { @@ -1035,12 +1048,7 @@ impl PdfCraftApp { let read = super::input::WebRead::start(&self.web_reads); wasm_bindgen_futures::spawn_local(async move { let _read = read; - if let Some(h) = rfd::AsyncFileDialog::new() - .add_filter_any_case("PDF", &["pdf"]) - .add_filter_any_case(tl!("Images and text"), &create_ui::CONVERTIBLE) - .pick_file() - .await - { + if let Some(h) = Self::open_picker().pick_file().await { let bytes = h.read().await; if let Ok(mut q) = inbox.lock() { q.push((h.file_name(), bytes)); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 90840225ef..79924f7128 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1448,6 +1448,8 @@ struct PageFrame<'a> { current_height: f32, pressed_page: Option, calc_hover: Option, + /// The form field widget under the pointer on any page, for its Mouse Enter and Exit scripts. + field_hover: Option, /// A page took this frame's right-click. right_clicked: bool, } @@ -1930,6 +1932,7 @@ fn draw_pages( current_height: f32::INFINITY, pressed_page: None, calc_hover: None, + field_hover: None, right_clicked: false, }; for &i in visible_pages { @@ -1944,10 +1947,12 @@ fn draw_pages( current_height, pressed_page, calc_hover, + field_hover, out: page_out, right_clicked, .. } = frame; + crate::forms_ui::hover_moved(view, field_hover); // A right-click no page took deselects the comment, so the page's menu opens, not the comment's, // and with Edit text the picture (one on a page went to that page, `page_input`). if resp.secondary_clicked() && !right_clicked { diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 2e540b2eb2..20d4617917 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -966,6 +966,9 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P } else { crate::forms_ui::paint_page(painter, &xf, i, info, form, view, input.hover.as_ref()); crate::forms_ui::tooltip(resp, form, input.hover.as_ref()); + if let Some(hovered) = crate::forms_ui::hovered(form, input.hover.as_ref()) { + frame.field_hover = Some(hovered); + } } // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index df8f77741c..f6144ec143 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -524,10 +524,22 @@ impl PdfCraftApp { // click that brought this pane forward took the focus). It goes in first, as it came first. let (other_applied, stay) = self.apply_other_pane_edit(i); let modal = self.modal_open(); + let generation = |app: &Self| app.views.get(i).and_then(|v| app.session.get(v.id)).map(|d| d.edit_generation()); + let before = generation(self); + let queued = self.views.get(i).is_some_and(|v| v.pending_edit.is_some()); let applied = self.apply_queued_edit(i) && other_applied; + // A click's Mouse Up waits on the edit it queued (a check box's toggle, a list's pick): + // when that was refused, the click did nothing and its Mouse Up doesn't run either. + if queued + && generation(self) == before + && let Some(view) = self.views.get_mut(i) + { + view.forms.triggers.retain(|(_, t)| *t != pdfcraft_engine::FieldTrigger::MouseUp); + } // After the queued edit: a value typed into the field the focus left has been taken (and // its change has run the field's exit) before the focus scripts run. self.run_focus_scripts(i); + self.run_field_triggers(i); if !applied { // Nor may a shortcut pressed with it (⌘S right after Enter) run behind the refusal. self.deferred_commands.clear(); @@ -818,7 +830,8 @@ impl PdfCraftApp { /// ([`crate::forms_ui::refresh_draft`]), so leaving the field doesn't write the old value back. fn run_focus_scripts(&mut self, i: usize) { let Some(view) = self.views.get_mut(i) else { return }; - let now = view.forms.focus.as_ref().map(|f| (f.name.clone(), f.widget)); + // The field's object too: moving between fields sharing a name is a move (#344 review). + let now = view.forms.focus.as_ref().map(|f| (f.name.clone(), f.obj, f.widget)); if view.forms.focused == now { return; } @@ -826,13 +839,13 @@ impl PdfCraftApp { let id = view.id; let edited = view.forms.focus.clone(); use pdfcraft_engine::js::FieldFocus; - for ((name, widget), focus) in [left.map(|l| (l, FieldFocus::Exit)), now.map(|n| (n, FieldFocus::Enter))].into_iter().flatten() { + for ((name, obj, widget), focus) in [left.map(|l| (l, FieldFocus::Exit)), now.map(|n| (n, FieldFocus::Enter))].into_iter().flatten() { // The edited field's value before this script, to tell its untouched draft from typing: // the exit of the field left may have changed it already, and the draft with it. let before: Option<(String, Vec)> = edited.as_ref().and_then(|e| { crate::forms_ui::focused_field(e, &self.session.get(id)?.form).map(|f| (crate::forms_ui::draft_text(f), f.value.clone())) }); - match self.session.field_focus(id, &name, widget, focus) { + match self.session.field_focus_target(id, &name, obj.map(pdfcraft_engine::js::field_object_id), widget, focus) { Ok(changed) => { if changed && let Some(info) = self.session.get(id).map(|d| d.info.clone()) @@ -854,6 +867,42 @@ impl PdfCraftApp { } } + /// The pointer triggers tab `i` queued (Mouse Enter, Exit, Down, and a check box's Mouse Up + /// after its toggle), each running its widget's JavaScript in the document's realm (#269). + fn run_field_triggers(&mut self, i: usize) { + let Some(view) = self.views.get_mut(i) else { return }; + let triggers = std::mem::take(&mut view.forms.triggers); + let id = view.id; + for ((name, object, widget), trigger) in triggers { + // The field being edited as it was, so a script changing it shows in its editor + // while a draft the user already typed into is kept (as `run_focus_scripts` does). + let before: Option<(String, Vec)> = self.views.get(i).and_then(|v| v.forms.focus.as_ref()).and_then(|e| { + crate::forms_ui::focused_field(e, &self.session.get(id)?.form).map(|f| (crate::forms_ui::draft_text(f), f.value.clone())) + }); + match self.session.field_trigger_target(id, &name, Some(object), widget, trigger) { + Ok(changed) => { + if changed + && let Some(info) = self.session.get(id).map(|d| d.info.clone()) + && let Some(view) = self.views.get_mut(i) + { + view.document_changed(&info); + if let Some(before) = &before + && let Some(draft) = view.forms.focus.as_mut() + && let Some(f) = self.session.get(id).and_then(|d| crate::forms_ui::focused_field(draft, &d.form)) + { + crate::forms_ui::refresh_draft(draft, (&before.0, &before.1), (crate::forms_ui::draft_text(f), &f.value)); + } + } + let out = self.session.take_js_output(id); + if !out.is_empty() { + self.handle_js(id, out); + } + } + Err(e) => self.notify_error(e), + } + } + } + /// Apply the edit queued on tab `i` (the active one). Returns false only when it was a /// field's typing and the field refused it (#166): the editor reopens with the text, and the /// user has been told why, so the typing isn't lost and the caller can stop. @@ -1578,6 +1627,43 @@ mod revert_draft_tests { assert_eq!(hint(&app), "", "hint's enter ran before the document is read"); assert_eq!(app.views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some(""), "and its editor shows it"); } + /// A pointer trigger whose script changes the field being edited shows the change in its + /// editor, unless the user already typed there: then the typed draft is kept. + #[test] + fn a_pointer_trigger_changing_the_edited_field_refreshes_an_untouched_draft() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (old) /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E << /S /JavaScript /JS (getField\\('name'\\).value = 'new';) >> >> >>".into(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + for (typed, shown) in [(false, "new"), (true, "typed")] { + let mut app = PdfCraftApp::new(); + app.open_bytes("hot.pdf", None, pdf.clone()).unwrap(); + let hot = app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == "hot").unwrap().obj; + let text = if typed { "typed" } else { "old" }; + app.views[0].forms.focus = Some(Focus { text: text.into(), picked: vec!["old".into()], ..focus("") }); + let hot = ("hot".to_string(), pdfcraft_engine::js::field_object_id(hot), 0); + app.views[0].forms.triggers.push((hot, pdfcraft_engine::FieldTrigger::MouseEnter)); + app.run_field_triggers(0); + assert_eq!(app.views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some(shown), "typed: {typed}"); + } + } + #[test] fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() { for queued in [false, true] { diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index ea29f7fe46..77eb9c2b58 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -128,6 +128,7 @@ impl PdfCraftApp { if !self.flatten_fill_sign_for_save(id) { return false; } + self.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillSave); let bytes = match self.session.save_bytes(id) { Ok(b) => b, Err(e) => { @@ -293,6 +294,7 @@ impl PdfCraftApp { self.notify_tr("The document was closed before it could be saved."); return false; }; + self.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillSave); let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }; let bytes = match bytes.and_then(|b| self.session.check_save(id, &b).map(|()| b)) { Ok(b) => b, diff --git a/crates/ui-egui/src/platform/open_worker.rs b/crates/ui-egui/src/platform/open_worker.rs index 8f7b0c5d3f..b9c3735e4a 100644 --- a/crates/ui-egui/src/platform/open_worker.rs +++ b/crates/ui-egui/src/platform/open_worker.rs @@ -20,7 +20,12 @@ pub(crate) enum Input { pub(crate) enum ResultKind { Conversion(Arc>), - Prepared { bytes: Arc>, result: Box> }, + /// An Office document LibreOffice converted here, off the frame thread, or why it couldn't. + Converted(Result>, String>), + Prepared { + bytes: Arc>, + result: Box>, + }, ReadFailed(String), RemoteFailed(String), RecoveryFailed(String), @@ -213,8 +218,11 @@ fn prepare(job: Job) -> ResultKind { }, Input::Bytes(bytes) => bytes.clone(), }; - if matches!(pdfcraft_engine::source_kind(&job.name, &bytes), Some(SourceKind::Image | SourceKind::Text)) { - return ResultKind::Conversion(bytes); + match pdfcraft_engine::source_kind(&job.name, &bytes) { + Some(SourceKind::Image | SourceKind::Text) => return ResultKind::Conversion(bytes), + // LibreOffice takes seconds: it runs here, and the frame thread only opens the result. + Some(SourceKind::Office) => return ResultKind::Converted(pdfcraft_engine::office_to_pdf(&job.name, &bytes).map_err(|e| e.to_string())), + Some(SourceKind::Pdf) | None => {} } let result = job.context.prepare_open(job.name, job.path, bytes.clone(), job.password.as_deref().map(String::as_str)); ResultKind::Prepared { bytes, result: Box::new(result) } diff --git a/crates/ui-egui/src/platform/open_worker/tests.rs b/crates/ui-egui/src/platform/open_worker/tests.rs index 62520f3edf..7f8d73fa8d 100644 --- a/crates/ui-egui/src/platform/open_worker/tests.rs +++ b/crates/ui-egui/src/platform/open_worker/tests.rs @@ -130,10 +130,15 @@ impl HeldToolOpen { self.started.recv_timeout(Duration::from_secs(5)).expect("the native open reached its preparation barrier"); } - #[allow(clippy::expect_used)] fn complete(&self, harness: &mut Harness<'static, PdfCraftApp>) { + self.complete_within(harness, Duration::from_secs(5)); + } + + /// [`Self::complete`] for a preparation that may take up to `timeout`. + #[allow(clippy::expect_used)] + fn complete_within(&self, harness: &mut Harness<'static, PdfCraftApp>, timeout: Duration) { self.release.send(()).expect("release the native preparation"); - self.finished.recv_timeout(Duration::from_secs(5)).expect("the native open returned its result"); + self.finished.recv_timeout(timeout).expect("the native open returned its result"); harness.run_steps(2); } } @@ -997,3 +1002,55 @@ fn a_password_prompt_keeps_a_place_in_the_open_queue_for_its_retry() { assert!(app.password_prompt.is_none(), "the retry is queued: {:?}", app.password_prompt.as_ref().and_then(|p| p.error.clone())); assert!(app.password_retry.is_some()); } + +/// A Word document built from the synthetic fixture (PdfCraft's own Word export). +#[allow(clippy::expect_used)] +fn synthetic_docx() -> Vec { + let mut session = pdfcraft_engine::Session::new(); + let id = session.open("source.pdf", None, Arc::new(fixture(1)), None).expect("open the fixture"); + session.get(id).expect("the fixture").export_office(pdfcraft_engine::compare::OfficeFormat::Docx) +} + +/// File ▸ Open of a Word document: LibreOffice runs on the open worker, never on the frame +/// thread, and the result opens as a new, unsaved PDF. Without LibreOffice the user is +/// told it is needed, rather than that the file is a broken PDF. +#[test] +#[allow(clippy::expect_used)] +fn file_open_converts_an_office_document_on_the_worker() { + let mut harness = tool_open_harness(); + let held = HeldToolOpen::install(&mut harness); + let file = tool_open_file("office", "docx", &synthetic_docx()); + harness.state_mut().pick_override = Some(vec![file.0.to_string_lossy().into_owned()]); + harness.state_mut().run_command("file.open"); + harness.run_steps(2); + held.wait_started(); + assert!(harness.state().views.is_empty(), "nothing opens before the conversion finishes"); + // An installed LibreOffice takes seconds; it is stopped after 120 s. + held.complete_within(&mut harness, Duration::from_secs(150)); + let app = harness.state(); + assert!(app.pending_opens.is_empty()); + if let Some(view) = app.views.first() { + let doc = app.session.get(view.id).expect("the converted document"); + // Named after the Word file: `.docx` opens as `.pdf`. + assert_eq!(doc.name, format!("pdfcraft-open-tool-{}-office.pdf", std::process::id())); + assert!(doc.path.is_none(), "the converted PDF is new and unsaved, not the .docx on disk"); + } else { + let message = app.toast.as_ref().map(|(message, _)| message.clone()).unwrap_or_default(); + assert!(message.contains("LibreOffice, which isn't installed"), "the failure names the missing dependency: {message}"); + } +} + +#[test] +fn the_open_picker_defaults_to_pdf_and_offers_images_office_text_and_all_files() { + let filters = PdfCraftApp::open_filters(); + let names: Vec<&str> = filters.iter().map(|(name, _)| name.as_str()).collect(); + #[cfg(not(target_os = "macos"))] + assert_eq!(names, ["PDF", "Images", "Office documents", "Text", "All files"]); + #[cfg(target_os = "macos")] + assert_eq!(names, ["PDF", "Images", "Office documents", "Text"]); + let extensions = |name: &str| filters.iter().find(|(n, _)| n == name).map(|(_, e)| e.clone()).unwrap_or_default(); + assert_eq!(extensions("PDF"), ["pdf"]); + assert_eq!(extensions("Images"), pdfcraft_engine::IMAGE_EXTENSIONS); + assert!(["doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "rtf"].iter().all(|e| extensions("Office documents").contains(e))); + assert_eq!(extensions("Text"), pdfcraft_engine::TEXT_EXTENSIONS); +} diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 50d4cba0bc..94aafda3f7 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -101,11 +101,18 @@ pub struct FormView { /// The field and widget that had focus when it was last looked at: when the focus moves, the /// one it left runs its XFA exit scripts and the one it reached its enter scripts /// (`PdfCraftApp::run_focus_scripts`). - pub(crate) focused: Option<(String, usize)>, + pub(crate) focused: Option<(String, Option, usize)>, /// The list box row clicked last, without Shift: (its field name, the row). A Shift-click picks /// the rows from it to the one clicked; the field's value can't say which row came last when /// rows share an export value, and `/I` lists them in ascending order. pub(crate) list_anchor: Option<(String, usize)>, + /// The field and widget under the pointer when it was last looked at: when it changes, the + /// one left runs its Mouse Exit JavaScript and the one reached its Mouse Enter (#269). + pub(crate) hovered: Option, + /// Pointer triggers waiting to run their widget's JavaScript, in order (field name, widget, + /// trigger): `PdfCraftApp::run_field_triggers` runs them after the frame's edit, so a check + /// box's Mouse Up sees the value its click just stored (#247). + pub(crate) triggers: Vec<(WidgetRef, pdfcraft_engine::FieldTrigger)>, } /// A form message for the app to show, with document data kept separate so the visible @@ -458,6 +465,38 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, form: &[FormField Some(Hit::new(Owner::Field { field, widget }, Layer::Field, icon)) } +/// A widget a pointer trigger is for: its field's name and object (so a field sharing another's +/// name runs its own script), and the widget's index in the field. +pub(crate) type WidgetRef = (String, u64, usize); + +/// `f`'s widget `widget` as a trigger names it. +fn widget_ref(f: &FormField, widget: usize) -> WidgetRef { + (f.name.clone(), pdfcraft_engine::js::field_object_id(f.obj), widget) +} + +/// The field and widget under the pointer on a page, when a press there would take it. +pub(crate) fn hovered(form: &[FormField], hover: Option<&Hit>) -> Option { + match hover.map(|h| h.owner) { + Some(Owner::Field { field, widget }) => form.get(field).map(|f| widget_ref(f, widget)), + _ => None, + } +} + +/// Queue Mouse Exit for the widget the pointer left and Mouse Enter for the one it reached, when +/// they differ from the last frame's (`now`: the widget under the pointer on any page). +pub(crate) fn hover_moved(view: &mut DocView, now: Option) { + if view.forms.hovered == now { + return; + } + let left = std::mem::replace(&mut view.forms.hovered, now.clone()); + if let Some(w) = left { + view.forms.triggers.push((w, pdfcraft_engine::FieldTrigger::MouseExit)); + } + if let Some(w) = now { + view.forms.triggers.push((w, pdfcraft_engine::FieldTrigger::MouseEnter)); + } +} + /// The tooltip (`/TU`) of the field under the pointer, as Acrobat shows it on hover (#3854): only /// where the field is what a press would take (`hover`, the top hit there). ISO 32000-2 Table 226 /// makes it the name the user interface identifies the field by. @@ -484,6 +523,10 @@ pub(crate) fn page_input( let PagePress::Mine(Owner::Field { field, widget: wi }) = press else { return false }; let Some((f, w)) = form.get(field).and_then(|f| f.widgets.get(wi).map(|w| (f, w))) else { return false }; let usable = usable(f, w, allowed); + // The press runs the widget's Mouse Down JavaScript, as in Acrobat (#269). + if usable && ptr.pressed().is_some() { + view.forms.triggers.push((widget_ref(f, wi), pdfcraft_engine::FieldTrigger::MouseDown)); + } // A text field opens on the press rather than the click, so a drag that starts on it // selects its text (#2213). if f.kind == FormFieldKind::Text @@ -544,6 +587,10 @@ pub(crate) fn page_input( commit(view, form); view.forms.focus = Some(open_focus(f, wi)); } + // A combo box's click runs its Mouse Up JavaScript (#269). + if f.kind == FormFieldKind::Combo { + view.forms.triggers.push((widget_ref(f, wi), pdfcraft_engine::FieldTrigger::MouseUp)); + } } FormFieldKind::List => { let Some(p) = ptr.within(xf.rect) else { return true }; @@ -589,6 +636,8 @@ pub(crate) fn page_input( view.pending_edit = Some(list_pick_edit(form, f, picked)); } } + // A list box's click runs its Mouse Up JavaScript after the pick is stored (#269). + view.forms.triggers.push((widget_ref(f, wi), pdfcraft_engine::FieldTrigger::MouseUp)); } } true @@ -1376,6 +1425,9 @@ pub(crate) fn overlay( view.forms.toggle = true; } else if toggle { edit = toggle_edit(f, focus.widget); + // The click's Mouse Up JavaScript runs once the toggle is stored, so it reads the + // new value (#247). + view.forms.triggers.push((widget_ref(f, focus.widget), pdfcraft_engine::FieldTrigger::MouseUp)); } else if space { // Space toggles a check box and selects a radio button, as in browsers: it never // turns the selected button off. diff --git a/crates/ui-egui/src/workflows/combine_ui/parts.rs b/crates/ui-egui/src/workflows/combine_ui/parts.rs index 4527ec061d..cc05552ba1 100644 --- a/crates/ui-egui/src/workflows/combine_ui/parts.rs +++ b/crates/ui-egui/src/workflows/combine_ui/parts.rs @@ -39,9 +39,15 @@ fn combinable(ext: &str) -> bool { } /// Whether Combine takes a file by what it is: a PDF (by its header or its name) or an image or -/// text file it converts, as `Session::as_pdf` tells them apart. +/// text file it converts, as `Session::as_pdf_inline` tells them apart. Not an Office document: +/// converting one runs LibreOffice for seconds, which Combine can't do on the frame thread, so +/// it is left out and named rather than listed as a file that can't be read. pub(super) fn takes(name: &str, bytes: &[u8]) -> bool { - pdfcraft_engine::source_kind(name, bytes).is_some() || name.to_ascii_lowercase().ends_with(".pdf") + match pdfcraft_engine::source_kind(name, bytes) { + Some(pdfcraft_engine::SourceKind::Office) => false, + Some(_) => true, + None => name.to_ascii_lowercase().ends_with(".pdf"), + } } /// A file's bookmark in the combined PDF: its name without ".pdf", or without the extension of diff --git a/crates/ui-egui/src/workflows/combine_ui/session.rs b/crates/ui-egui/src/workflows/combine_ui/session.rs index 2297e70933..124260031c 100644 --- a/crates/ui-egui/src/workflows/combine_ui/session.rs +++ b/crates/ui-egui/src/workflows/combine_ui/session.rs @@ -279,7 +279,7 @@ impl PdfCraftApp { notes: Vec::new(), }; let source = bytes.clone(); - let (bytes, assessed) = match self.session.as_pdf(&name, &bytes) { + let (bytes, assessed) = match self.session.as_pdf_inline(&name, &bytes) { Ok(pdf) => { let assessed = assess(&pdf, None); (pdf, assessed) diff --git a/crates/ui-egui/src/workflows/create_multiple_ui.rs b/crates/ui-egui/src/workflows/create_multiple_ui.rs index 54d2181d0a..03fd3e1eaa 100644 --- a/crates/ui-egui/src/workflows/create_multiple_ui.rs +++ b/crates/ui-egui/src/workflows/create_multiple_ui.rs @@ -43,7 +43,7 @@ impl PdfCraftApp { continue; } }; - let (kind, pdf) = match self.session.convert_to_pdf(&name, &Arc::new(bytes)) { + let (kind, pdf) = match self.session.convert_to_pdf_inline(&name, &Arc::new(bytes)) { Ok(converted) => converted, Err(error) => { refused.push(format!("{name} ({error})")); @@ -111,7 +111,7 @@ impl PdfCraftApp { let sources = std::iter::from_fn(|| { loop { match files.next()? { - Ok((name, bytes)) => match session.convert_to_pdf(&name, &Arc::new(bytes)) { + Ok((name, bytes)) => match session.convert_to_pdf_inline(&name, &Arc::new(bytes)) { Ok((_, pdf)) => { converted += 1; return Some(Ok((stem(&name).to_string(), pdf, None))); diff --git a/crates/ui-egui/src/workflows/create_ui.rs b/crates/ui-egui/src/workflows/create_ui.rs index f74ed10535..5d3d6f4cc6 100644 --- a/crates/ui-egui/src/workflows/create_ui.rs +++ b/crates/ui-egui/src/workflows/create_ui.rs @@ -43,9 +43,6 @@ pub(crate) fn image_import_body(ui: &mut egui::Ui, app: &mut PdfCraftApp) -> (bo (go, cancel) } -/// File types Open accepts besides PDF (converted on open). -pub use pdfcraft_engine::CONVERTIBLE; - /// What Create ▸ Clipboard found on the clipboard. #[derive(Clone, Debug, PartialEq)] pub enum Clip { @@ -105,15 +102,23 @@ fn stem(name: &str) -> &str { } impl PdfCraftApp { - /// Convert a non-PDF file (image, text) into a new tab. Returns `None` when `bytes` is not - /// something Create understands (the caller then tries to open it as a PDF). + /// Convert a non-PDF file (image, text, Office document) into a new tab. Returns `None` when + /// `bytes` is not something Create understands (the caller then tries to open it as a PDF). + /// An Office document blocks while LibreOffice runs: File ▸ Open converts it on the open + /// worker instead ([`Self::open_converted_pdf`]), leaving this for startup and inline opens. pub(crate) fn open_converted(&mut self, name: &str, bytes: &[u8]) -> Option> { use pdfcraft_engine::SourceKind; - if !matches!(pdfcraft_engine::source_kind(name, bytes), Some(SourceKind::Image | SourceKind::Text)) { + if !matches!(pdfcraft_engine::source_kind(name, bytes), Some(SourceKind::Image | SourceKind::Text | SourceKind::Office)) { return None; } let created = self.session.convert_to_pdf(name, &Arc::new(bytes.to_vec())).map(|(_, pdf)| pdf); - Some(self.open_created_bytes(&format!("{}.pdf", stem(name)), created.map_err(|e| e.to_string()))) + Some(self.open_converted_pdf(name, created.map_err(|e| e.to_string()))) + } + + /// Open `created`, the PDF converted from the file `name`, as a new, unsaved tab named after + /// it (`Report.docx` → `Report.pdf`). + pub(crate) fn open_converted_pdf(&mut self, name: &str, created: Result>, String>) -> Result<(), String> { + self.open_created_bytes(&format!("{}.pdf", stem(name)), created) } fn open_created_bytes(&mut self, name: &str, created: Result>, String>) -> Result<(), String> { diff --git a/crates/ui-egui/src/workflows/files.rs b/crates/ui-egui/src/workflows/files.rs index 18c1c24e0f..82057ecc55 100644 --- a/crates/ui-egui/src/workflows/files.rs +++ b/crates/ui-egui/src/workflows/files.rs @@ -666,7 +666,7 @@ impl PdfCraftApp { continue; } }; - match self.session.insert_source(id, &name, &Arc::new(bytes)) { + match self.session.insert_source_inline(id, &name, &Arc::new(bytes)) { Ok((pages, bytes)) => { if self.apply_edit(Edit::InsertPagesFrom { name, diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 58a62deb29..303aa61a41 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -232,6 +232,23 @@ impl PdfCraftApp { } } + /// Run document `id`'s catalog JavaScript for `event` (Will Save before a save, Will Print + /// before printing) and show what it said. What it changed is part of what is saved or + /// printed. A failing script is reported and the save or print still goes ahead. + pub(crate) fn run_document_actions(&mut self, id: DocId, event: pdfcraft_engine::js::DocumentScriptEvent) { + let before = self.session.get(id).map(|d| d.edit_generation()); + let out = self.session.run_document_actions(id, event); + if self.session.get(id).map(|d| d.edit_generation()) != before + && let Some(i) = self.views.iter().position(|v| v.id == id) + && let Some(info) = self.session.get(id).map(|d| d.info.clone()) + { + self.views[i].document_changed(&info); + } + if !out.is_empty() { + self.handle_js(id, out); + } + } + /// Run a push button's JavaScript (its Mouse Up action). pub fn run_button_script(&mut self, id: DocId, field: &str, script: &str) { self.run_button_script_target(id, field, None, script); diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index aa02635110..3ea2099a96 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -633,6 +633,16 @@ impl PdfCraftApp { return false; } let Some((_, id)) = self.active_ids() else { return false }; + // A form's Will Print script runs first: what it changes is printed. + if self.session.get(id).is_some_and(|d| d.allows_printing()) { + let before = self.session.get(id).map(|d| d.edit_generation()); + self.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillPrint); + // What it showed may lie outside the printed areas Label printing found: find them again. + if self.session.get(id).map(|d| d.edit_generation()) != before && self.print_draft.handling == Handling::Label { + self.print_draft.crop_pages = None; + self.read_label_crops(id); + } + } let Some(doc) = self.session.get(id) else { return false }; let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); let settings = match self.print_draft.settings(doc.info.pages.len(), &labels) { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 719f871f48..60c44bbfd5 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -1839,10 +1839,10 @@ fn images_and_text_files_join_the_combine_list_as_pdfs() { #[test] fn files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees() { let mut h = combine_of(&[("one.pdf", 2)]); - let bad = vec![("broken.jpg".to_string(), b"\xff\xd8\xff\xe0 not a JPEG".to_vec()), ("report.docx".to_string(), b"PK\x03\x04".to_vec())]; + let bad = vec![("broken.jpg".to_string(), b"\xff\xd8\xff\xe0 not a JPEG".to_vec()), ("archive.zip".to_string(), b"PK\x03\x04".to_vec())]; h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::Combine, bad); h.run_steps(3); - assert_eq!(combine_names(&h), ["one.pdf", "broken.jpg", "report.docx"]); + assert_eq!(combine_names(&h), ["one.pdf", "broken.jpg", "archive.zip"]); for f in &h.state().combine_draft[1..] { let why = f.problem.as_deref().expect("why it can't be combined"); assert!(why.starts_with("Can't be read: ") && !why.contains(&f.name), "{why}"); @@ -1859,7 +1859,7 @@ fn files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees() { h.run_steps(3); h.get_by_label("Leave out files that can't be read?"); h.get_by_label("• broken.jpg"); - h.get_by_label("• report.docx"); + h.get_by_label("• archive.zip"); h.get_by_label("Cancel").click(); h.run_steps(3); assert!(h.query_by_label("Leave out files that can't be read?").is_none()); @@ -2136,7 +2136,7 @@ fn plus_between_pages_inserts_picked_files_there_in_order() { assert_eq!(texts_of(h.state(), 0), ["a note", "Page 1", "a note", "Page 2", "Page 3", "a note"]); // A file that can't be converted changes nothing. let before = texts_of(h.state(), 0); - h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::InsertPages, vec![("report.docx".into(), b"PK\x03\x04".to_vec())]); + h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::InsertPages, vec![("archive.zip".into(), b"PK\x03\x04".to_vec())]); h.run_steps(2); assert_eq!(texts_of(h.state(), 0), before); } diff --git a/crates/ui-egui/tests/it/field_triggers.rs b/crates/ui-egui/tests/it/field_triggers.rs new file mode 100644 index 0000000000..f648b7a445 --- /dev/null +++ b/crates/ui-egui/tests/it/field_triggers.rs @@ -0,0 +1,104 @@ +//! A form field widget's pointer scripts in the app (#269, ported from #344): the pointer +//! moving onto and off a widget runs its Mouse Enter and Mouse Exit JavaScript, and a click on a +//! check box runs its Mouse Up JavaScript after the click stored the new value (#247). + +use egui_kittest::Harness; +use pdfcraft_ui_egui::PdfCraftApp; + +/// `log` records what ran; `hot` (a text field) logs the pointer entering and leaving it; +/// `strike` (a check box) shows or hides `note` from its Mouse Up script by its own new value. +fn form() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 8 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 350 280 370] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 250 280 290] /P 3 0 R /F 4 /AA << /E << /S /JavaScript /JS (getField\\('log'\\).value += 'in;';) >> /X << /S /JavaScript /JS (getField\\('log'\\).value += 'out;';) >> >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (strike) /V /Off /AS /Off /Rect [20 300 50 330] /P 3 0 R /F 4 /MK << /CA (4) >> /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> /A << /S /JavaScript /JS (getField\\('note'\\).display = event.target.value == 'Off' ? display.hidden : display.visible; getField\\('log'\\).value += 'up:' + event.target.value + ';';) >> >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /V (struck) /Rect [60 300 280 330] /P 3 0 R /F 6 >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 0 >>\nstream\n\nendstream", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.set_option("left", "closed").unwrap(); + app.open_bytes("triggers.pdf", None, form()).unwrap(); + app + }); + crate::support::settle(&mut h); + h +} + +fn field<'a>(app: &'a PdfCraftApp, name: &str) -> &'a pdfcraft_engine::FormField { + let id = app.active_ids().unwrap().1; + app.session.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap() +} + +fn centre(h: &Harness<'static, PdfCraftApp>, name: &str) -> egui::Pos2 { + let app = h.state(); + let (index, id) = app.active_ids().unwrap(); + let doc = app.session.get(id).unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&app.views[index], &doc.info, field(app, name), 0).unwrap().center() +} + +fn log(h: &Harness<'static, PdfCraftApp>) -> String { + field(h.state(), "log").value.join(",") +} + +#[test] +fn the_pointer_entering_and_leaving_a_widget_runs_its_scripts() { + let mut h = harness(); + let hot = centre(&h, "hot"); + let away = hot + egui::vec2(0.0, 200.0); + h.hover_at(away); + h.run_steps(2); + h.hover_at(hot); + crate::support::wait_until(&mut h, "Mouse Enter ran", |app| field(app, "log").value.join(",") == "in;"); + // Moving within the widget runs nothing more. + h.hover_at(hot + egui::vec2(10.0, 0.0)); + h.run_steps(3); + assert_eq!(log(&h), "in;"); + h.hover_at(away); + crate::support::wait_until(&mut h, "Mouse Exit ran", |app| field(app, "log").value.join(",") == "in;out;"); +} + +/// The check box's Mouse Up reads the value its click stored: checking it shows the note, +/// unchecking hides it again (#247). +#[test] +fn a_check_box_mouse_up_script_sees_the_value_its_click_stored() { + let mut h = harness(); + let strike = centre(&h, "strike"); + let click = |h: &mut Harness<'static, PdfCraftApp>| { + h.hover_at(strike); + h.run_steps(1); + h.drag_at(strike); + h.run_steps(1); + h.drop_at(strike); + h.run_steps(1); + }; + click(&mut h); + crate::support::wait_until(&mut h, "checked and its Mouse Up ran", |app| field(app, "log").value.join(",") == "up:Yes;"); + assert_eq!(field(h.state(), "strike").value, ["Yes"]); + assert!(!field(h.state(), "note").widgets[0].hidden, "shown by the script"); + h.run_steps(6); + click(&mut h); + crate::support::wait_until(&mut h, "unchecked and its Mouse Up ran", |app| field(app, "log").value.join(",") == "up:Yes;up:Off;"); + assert!(field(h.state(), "note").widgets[0].hidden, "hidden again"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index ea5dec6e61..0aae004df6 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -43,6 +43,7 @@ mod download_mime; mod editing; mod editor_taps; mod export; +mod field_triggers; mod fill_sign; mod find_replace; mod folders; diff --git a/docs/acroform-script-workflows.md b/docs/acroform-script-workflows.md index 5d4059f43e..49666af3d0 100644 --- a/docs/acroform-script-workflows.md +++ b/docs/acroform-script-workflows.md @@ -145,9 +145,19 @@ supply an anchored renderer through the UI menu callback when it adds link actio the shared parser lands upstream, this integration can use it directly. `ScriptSession::run_host_task` gives future timer and page actions (#4751) access to the -retained realm on its own worker. `DocumentScriptEvent` and `run_document_script_event` -provide explicit entry points for catalog OpenAction, WillSave and WillPrint (#344). -The callers for those document actions are part of #344. +retained realm on its own worker. + +Document actions and widget triggers (ported from #344 by @DWAA1660) run through the same +realm. On opening, the document scripts run, then the catalog `/OpenAction`'s JavaScript +and its `/Next` chain (event `Doc/Open`); Revert runs them again in a fresh realm. +`Session::run_document_actions` runs `/AA /WS` before a save and `/AA /WP` before printing: +the app's Save and Print and the `doc_save` and `doc_print` tools call it first. A failing +action is reported and never stops the save or print. `Session::field_trigger` runs a +widget's Mouse Enter, Mouse Exit, Mouse Down, Mouse Up, Focus or Blur JavaScript; the app +queues them from the pointer and the focus, and `js_event` runs them headlessly. A check +box's or radio button's Mouse Up runs after its click has stored the new value (#247). +Scripts that run unasked (open, Will Save, Will Print, pointer over, focus) may only ask for +highlighting, zoom, focus or a page. The lexical preflight recognizes unambiguous top-level and block-body ASI boundaries before identifiers, including declarations and assignments separated by line breaks or comments. It preserves counters after operators and member access, and across binary keyword operators. It distinguishes control-statement parentheses for regexp literals; inside expression parentheses, array/object literals and template interpolations it remains conservative; this is not a complete ECMAScript parser or ASI implementation. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 288b7ca0cf..eb38bc65cf 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-19 · **Change:** patch (`.p7b`/`.p7c` bundles read for trust import) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (Word, Excel, PowerPoint, OpenDocument and RTF open through an installed LibreOffice; `.p7b`/`.p7c` bundles read for trust import) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Every format Acrobat Pro reads or writes, and PdfCraft's support for it. Acrobat's list comes from its bundle's `Info.plist` `CFBundleDocumentTypes` (what it opens) and its public documentation @@ -68,9 +68,9 @@ features total). | Plain text | yes | yes | good | `create.from-text` | | Clipboard (image or text) | yes | yes | good | `create.from-clipboard` | | Screenshot | yes | no | none | `create.from-screenshot` planned | -| Word, Excel, PowerPoint (and Visio, Publisher on Windows) | yes | no | none | `create.from-office` planned (LibreOffice sidecar) | +| Word, Excel, PowerPoint (and Visio, Publisher on Windows) | yes | yes, desktop only, through an installed LibreOffice (File ▸ Open); not Visio or Publisher | good where LibreOffice is (its layout, not measured against Acrobat) | `create.from-office` partial | | HTML file, web page URL | yes | no | none | `create.from-html`, `create.from-web-page` planned | -| RTF | yes | no | none | | +| RTF | yes | yes, through LibreOffice (as Office documents) | good where LibreOffice is | `create.from-office` partial | | PostScript / EPS (Distiller) | yes | no | none | `create.distiller` planned (P3) | | Markdown | no | no | none | `create.from-markdown` planned (P2; not an Acrobat feature) | | Scanner | yes | no | none | `ocr.scanner-acquire` planned | @@ -110,6 +110,7 @@ features total). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Office documents (Word, Excel, PowerPoint, OpenDocument, RTF) open from File ▸ Open as new PDFs, converted by an installed LibreOffice; `create.from-office` partial. | | 2026-10-19 | patch | Certificates row: `.p7b`/`.p7c` bundles (DER or PEM `PKCS7`/`CMS`) are read for trust import. | | 2026-10-12 | minor | XDP split into data (read and write for XFA forms, already shipped under `form.xfa-data`) and opening `.xdp` as a document (none). Create PDF reads BigTIFF; malformed GIF headers are no longer detected as images. | | 2026-10-11 | minor | #773: inferred tables have no invented borders; rotated or overlapping invoice pages use floating shapes and page sections. Full Office layout fidelity remains partial. | diff --git a/docs/gaps.md b/docs/gaps.md index 8d472eb2d5..1a1c04a9da 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro +> **Last reviewed:** 2026-11-11 | **Last updated:** 2026-11-11 | **Change:** minor (gap 8: known residual risk for the LibreOffice sidecar, sandbox tracked in #6953; estimates unchanged) > **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (gaps 5 and 14 current state: native release Hans face via LXGW WenKai Lite; Arabic logical catalog, explicit RTL direction and mirrored stock chrome, with native/browser/IME workflows unqualified; no readiness re-estimate) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 3: #2517 a blank line stays a paragraph break; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -45,7 +46,7 @@ update the numbers. | 5 | Chinese UI lacks a web glyph face | localization | 6–12 | [localization-parity.md](localization-parity.md) | | 6 | Open-issue backlog of wrong results in shipped features | stability | 45–80 | this file | | 7 | OCR reads only unaccented Latin | feature | 40–75 | [file-format-parity.md](file-format-parity.md) | -| 8 | Office export weak, Office import absent | file format | 55–100 | [file-format-parity.md](file-format-parity.md) | +| 8 | Office export weak, Office import basic | file format | 55–100 | [file-format-parity.md](file-format-parity.md) | | 9 | No Preflight; PDF/A partial; PDF/X and PDF/UA absent | standards | 65–115 | [pdf-spec-parity.md](pdf-spec-parity.md) | | 10 | Signatures: no PKCS #11, timestamp servers or online revocation | feature, hardware | 40–70 | [pdf-spec-parity.md](pdf-spec-parity.md) | | 11 | Accessibility: checker only, no tagging tools | feature | 40–70 | [target-app-parity.md](target-app-parity.md) | @@ -189,19 +190,29 @@ update the numbers. - **Estimate:** 40–75 h, partly blocked on openly licensed models. **Doc:** [file-format-parity.md](file-format-parity.md). -### 8. Office export weak, Office import absent +### 8. Office export weak, Office import basic -- **Missing:** Word export that keeps layout; Excel and PowerPoint export; creating PDFs from Office, - HTML, web pages and PostScript; SVG/XML/EPS/PS/JPEG 2000 export. +- **Missing:** Word export that keeps layout; Excel and PowerPoint export; creating PDFs from HTML, + web pages and PostScript; SVG/XML/EPS/PS/JPEG 2000 export. Office documents open from File ▸ Open + through an installed LibreOffice, but Insert pages, Combine files and Create ▸ Multiple files + don't take them yet. - **Evidence:** `create.export-docx` partial; the synthetic invoice regression for [#773](https://github.com/storytold/pdfcraft/issues/773) now covers absent inferred borders, floating watermark text and per-page section sizes. Exact font matching, vector rules and broader layout fidelity remain incomplete; - `create.export-xlsx`, `create.export-pptx`, `create.from-office`, `create.from-html` planned. + `create.from-office` partial (LibreOffice sidecar; Writer and Calc never update linked content, + but Impress has no such setting and the process's network access isn't sandboxed); + `create.export-xlsx`, `create.export-pptx`, + `create.from-html` planned. +- **Known residual risk:** conversion runs the user's installed LibreOffice. PdfCraft + opens no connection itself. Macros are disabled, untrusted referer links are blocked, + and Writer and Calc never update links. Impress has no link-update setting, and the + `soffice` process isn't network-sandboxed, so linked content in presentations could be + fetched. Sandboxing the converter is [#6953](https://github.com/storytold/pdfcraft/issues/6953). - **Impact:** "Export PDF" and "Create PDF" are two of Acrobat's most used tools. - **Gate:** lossy or missing sub-cases of the convert-and-share workflow (partial, not blocking). - First beta items: Create PDF from Office (LibreOffice sidecar, 10–20 h) and a Word export that - keeps layout (20–35 h). + First beta items: the rest of Create PDF from Office (Insert, Combine and Create ▸ Multiple files + on a worker, a Create ▸ From Office command; 5–10 h) and a Word export that keeps layout (20–35 h). - **Estimate:** 55–100 h. **Doc:** [file-format-parity.md](file-format-parity.md). ### 9. No Preflight; PDF/A partial; PDF/X and PDF/UA absent @@ -422,6 +433,8 @@ update the numbers. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Gap 8: known residual risk for the LibreOffice sidecar (Impress has no link-update setting; the soffice process isn't network-sandboxed). Sandboxing tracked in #6953. Estimates unchanged. | +| 2026-10-26 | minor | Gap 8: the LibreOffice profile never updates Writer or Calc linked content on load; Impress has no such setting and the process's network access isn't sandboxed. Estimates unchanged. | | 2026-10-25 | minor | Gaps 5 and 14 current state: native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Hans face; Arabic catalogs stay logical with explicit RTL label direction and mirrored stock chrome, while native/browser RTL input, IME and complex workflows remain unqualified. Estimates unchanged. | | 2026-10-18 | minor | Gap 3: #2517 a partial rewrite keeps a kept source row in the paragraph and leaves the next marked region closed around its text. Estimates unchanged. | | 2026-10-15 | minor | Gap 3: #2517 a blank line at ordinary leading stays a paragraph break. Estimates unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index e276a51f44..7ca83dedd9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -288,7 +288,7 @@ total matches the parity estimate. | 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). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. 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 | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Retained text/image-bearing or unreadable tiling-pattern cells now make Apply refuse atomically after a bounded check of rewritten resources; fully removed cells still apply, and cross-page retention is checked. The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork, placed in the widget's bottom-right corner as it lies on the page, at a quarter of its shorter side and 48 screen points at most, so they annotate the artwork from a corner instead of covering the middle of it and turn with the page and the view (a field falling under 32 screen points, as a page does when zoomed out to fit, keeps its outline), snapshot tabs that reuse instead of multiplying, named-layer signature appearances redrawn for their real state (#79; display, the CLI's render, exports and printing, never saves; captions in the interface language, Chinese in English; the badge stays). Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. DocumentDuplex-only bindings use the supported scope through craft-print 9fe50c94. create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. 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`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Web OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, shipped). Missing: OCR languages beyond Latin, web OCR near native speed, 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. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. DocumentDuplex-only bindings use the supported scope through craft-print 9fe50c94. create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: Word, Excel, PowerPoint, OpenDocument and RTF files open from File ▸ Open as new PDFs through an installed LibreOffice (`create.from-office` partial); 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). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. 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`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Web OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, shipped). Missing: OCR languages beyond Latin, web OCR near native speed, 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. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). | | 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). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178); black-and-white images stored losslessly as CCITT Group 4 when smaller, averaged down to 300 ppi above 450 (halftones aside), Group 3 stored again as Group 4, and black-and-white RGB and indexed images that use only palette entries 0 and 1 packed to one bit. Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 126496547d..2d219b096a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -212,7 +212,7 @@ full parity in that area. | G Protect, redact, sanitize | 41 | 23 | 2 | 58.5% | 55% | 15–30 | Passwords and permissions, Remove security, redaction that removes glyphs, pixels, vectors, annotations and tags, with verification; word lists, patterns, FOIA/Privacy Act codes; sanitize. Missing: certificate security, pattern locales, custom code sets, partial-word redaction, folder redaction, protected view | | H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version | | I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners. Background removal, edge shadow removal and despeckle work on scanned pages (Enhance scanned file) but not yet as options of Recognize text, nor on CMYK, indexed, JBIG2 or CCITT scans | -| J Create and export | 34 | 12 | 3 | 39.7% | 25% | 55–100 | Create from images, multipage TIFF, text, clipboard, blank, many files at once; export PNG/JPEG/TIFF, text, RTF; Word and HTML partial. Missing: Office/HTML/web/PostScript to PDF, Excel and PowerPoint export, Word layout fidelity (#773), SVG/XML/EPS/PS/JPEG 2000 export, accessible text | +| J Create and export | 34 | 12 | 3 | 39.7% | 25% | 55–100 | Create from images, multipage TIFF, text, clipboard, blank, many files at once; export PNG/JPEG/TIFF, text, RTF; Word and HTML partial. Office documents open through an installed LibreOffice (File ▸ Open only). Missing: HTML/web/PostScript to PDF, Excel and PowerPoint export, Word layout fidelity (#773), SVG/XML/EPS/PS/JPEG 2000 export, accessible text | | K Optimize and standards | 58 | 15 | 11 | 35.3% | 20% | 65–115 | Reduce File Size and the Optimizer (images, CCITT and lossless JBIG2 monochrome compression, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, lossy/refinement JBIG2 | | L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux, the Windows spooler (#756) and the browser's print dialog on the web. Missing: print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) | | M Accessibility | 61 | 38 | 1 | 63.1% | 30% | 40–70 | Accessibility Checker (all 32 rules, report, fixes), alternate text, title and language. Missing: autotag, Tags/Order/Content panels, Reading Order tool, artifact marking, table editor, Read Out Loud, full keyboard operation of the app, screen-reader audit | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f481fc1f11..399e4378d1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5348,11 +5348,11 @@ title = "Document actions (will close, will save, did save, will print, did prin area = "F" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["file.save", "print.dialog"] +tools = ["doc_open", "doc_save", "doc_print"] +evidence = ["crates/engine/src/document_actions_tests.rs::the_open_action_runs_once_on_opening_in_the_document_realm", "crates/engine/src/document_actions_tests.rs::an_open_action_chain_runs_in_order_and_a_cycle_runs_each_action_once", "crates/engine/src/document_actions_tests.rs::an_open_action_may_not_open_a_url_or_print", "crates/engine/src/document_actions_tests.rs::the_open_action_does_not_run_with_javascript_off", "crates/engine/src/document_actions_tests.rs::revert_runs_the_open_action_again_in_a_fresh_realm", "crates/engine/src/document_actions_tests.rs::will_save_changes_what_is_saved_and_a_failing_one_does_not_stop_the_save", "crates/engine/src/document_actions_tests.rs::will_print_runs_in_the_document_realm", "crates/automation/tests/it/document_actions.rs::open_will_save_will_print_and_field_events_share_the_document_realm"] +notes = "The catalog /OpenAction's JavaScript (with its /Next chain, bounded and cycle-safe) runs once on opening after the document scripts, and again on Revert in a fresh realm; /AA /WS runs before a save (the app's Save and Save As, doc_save) and /AA /WP before printing (the app's Print, doc_print), in the document's realm, so what they change is saved or printed. A failing action is reported and never stops the save or print. These scripts run unasked, so they may only ask for highlighting, zoom, focus or a page. Missing: Will Close (/WC), Did Save (/DS), Did Print (/DP) and page open/close actions (#269, ported from #344)." [[feature]] id = "form.js-host" @@ -5362,9 +5362,9 @@ tier = "P0" milestone = "M6" status = "partial" commands = ["tools.js_console"] -tools = ["js_run", "js_advance"] -evidence = ["crates/automation/tests/it/timesheet_compat.rs::timesheet_keypad_geometry_icons_calculation_and_persistence", "crates/automation/tests/it/timesheet_compat.rs::popup_and_manual_response_resume_the_same_script_and_validate_choices", "crates/js/src/session.rs::document_realm_keeps_keypad_state_and_isolates_documents", "crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes", "crates/js/src/session.rs::timers_fire_in_the_document_realm_until_cleared", "crates/engine/src/live_scripts_tests.rs::a_timer_giving_a_button_another_icon_draws_that_icon_in_its_place", "crates/engine/src/live_scripts_tests.rs::get_ocgs_turns_a_layer_in_the_view"] -notes = "Document-scoped native Boa realms shared by button, console and field events; synchronous calculateNow in CO order; normal captions/icons; rect and appearance/tooltip writeback; effective page boxes; native explicit-button popup/response/alert continuation and viewer requests, including opening or saving attachments from form buttons. Headless js_run has deterministic ordered answers. Partial: web prompt UI, timers, annotation/layer APIs, non-normal button faces, widget-indexed Field access and remaining document action triggers are not implemented. See docs/acroform-script-workflows.md for lifecycle, limits and validation scope. app.setInterval/setTimeOut/clearInterval/clearTimeOut (at most 64 timers, 10 ms minimum) fire in the document realm on the viewer clock (js_advance headless); timer alerts and requests go to the console. this.getOCGs() with ocg.name/state/initState turns layers as the Layers panel does." +tools = ["js_run", "js_event", "js_advance"] +evidence = ["crates/js/src/tests.rs::to_source_writes_plain_state_as_a_literal", "crates/js/src/tests.rs::to_source_refuses_cycles_functions_and_oversized_state", "crates/js/src/tests.rs::set_items_and_set_action_change_the_field", "crates/engine/src/document_actions_tests.rs::set_items_and_set_action_are_written_to_the_document", "crates/engine/src/document_actions_tests.rs::pointer_and_focus_triggers_run_the_widget_scripts", "crates/engine/src/document_actions_tests.rs::only_a_click_trigger_may_print", "crates/engine/src/document_actions_tests.rs::a_check_box_mouse_up_reads_the_value_its_click_stored", "crates/ui-egui/tests/it/field_triggers.rs::the_pointer_entering_and_leaving_a_widget_runs_its_scripts", "crates/ui-egui/tests/it/field_triggers.rs::a_check_box_mouse_up_script_sees_the_value_its_click_stored", "crates/automation/tests/it/timesheet_compat.rs::timesheet_keypad_geometry_icons_calculation_and_persistence", "crates/automation/tests/it/timesheet_compat.rs::popup_and_manual_response_resume_the_same_script_and_validate_choices", "crates/js/src/session.rs::document_realm_keeps_keypad_state_and_isolates_documents", "crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes", "crates/js/src/session.rs::timers_fire_in_the_document_realm_until_cleared", "crates/engine/src/live_scripts_tests.rs::a_timer_giving_a_button_another_icon_draws_that_icon_in_its_place", "crates/engine/src/live_scripts_tests.rs::get_ocgs_turns_a_layer_in_the_view"] +notes = "Document-scoped native Boa realms shared by button, console and field events; synchronous calculateNow in CO order; normal captions/icons; rect and appearance/tooltip writeback; effective page boxes; native explicit-button popup/response/alert continuation and viewer requests, including opening or saving attachments from form buttons. Headless js_run has deterministic ordered answers. A widget's Mouse Enter, Mouse Exit, Mouse Down, Focus and Blur JavaScript run in the app and through js_event (and form_focus for focus), and a check box's or radio button's Mouse Up runs after its click stored the new value (#247); field.setItems, field.setAction and a bounded Object.prototype.toSource (plain data only) are written back to the document (#269, ported from #344). Partial: web prompt UI, non-normal button faces, widget-indexed Field access, and Will Close / Did Save / Did Print are not implemented; annotation scripting covers only view-only display and appearance changes. See docs/acroform-script-workflows.md for lifecycle, limits and validation scope. app.setInterval/setTimeOut/clearInterval/clearTimeOut (at most 64 timers, 10 ms minimum) fire in the document realm on the viewer clock (js_advance headless); timer alerts and requests go to the console. this.getOCGs() with ocg.name/state/initState turns layers as the Layers panel does." [[feature]] id = "form.js-console" @@ -7140,11 +7140,25 @@ title = "Create PDF from Office documents (LibreOffice sidecar)" area = "J" tier = "P2" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["file.open"] +tools = ["doc_create_multiple", "doc_combine"] +evidence = [ + "crates/create/src/office.rs::office_files_are_recognised_by_extension_and_signature", + "crates/create/src/office.rs::other_files_are_not_office_documents", + "crates/create/src/office.rs::a_converter_that_hangs_is_stopped", + "crates/create/src/office.rs::a_converter_that_makes_no_pdf_is_reported", + "crates/create/src/office.rs::libreoffice_converts_a_document_when_installed", + "crates/create/src/office.rs::the_profile_turns_off_macros_and_never_updates_links", + "crates/create/src/office.rs::a_timeout_stops_the_processes_the_converter_started", + "crates/automation/tests/it/office.rs::office_documents_convert_separately_with_their_text", + "crates/automation/tests/it/office.rs::office_documents_combine_in_order_with_a_bookmark_each", + "crates/engine/src/tests.rs::office_documents_convert_through_libreoffice_or_explain_why_not", + "crates/engine/src/tests.rs::inline_conversions_refuse_office_documents_without_running_libreoffice", + "crates/ui-egui/src/platform/open_worker/tests.rs::file_open_converts_an_office_document_on_the_worker", + "crates/ui-egui/src/platform/open_worker/tests.rs::the_open_picker_defaults_to_pdf_and_offers_images_office_text_and_all_files", +] +notes = "File ▸ Open (desktop) opens Word, Excel, PowerPoint, OpenDocument and RTF files (Office documents filter; PDF stays the default) as new, unsaved PDFs, converted on the open worker by an installed LibreOffice run headless in a private temporary folder with a fresh profile (macros disabled, untrusted links blocked, Writer and Calc linked content never updated) and stopped after 120 s. Found through PDFCRAFT_SOFFICE, the PATH, or the usual install folders; without it the user is told LibreOffice is needed. The automation tools that convert files (doc_create_multiple, doc_combine, batch convert) take Office documents too. Not yet: Insert pages, Combine files and Create ▸ Multiple files in the app refuse Office documents (they convert on the frame thread) and point to File ▸ Open; no Create ▸ From Office command or conversion settings; Visio and Publisher; the web build; layout fidelity is LibreOffice's, not measured against Acrobat; Impress has no link-update setting and the process's network access isn't sandboxed (synthetic linked sections, pictures and cell references made no connection with LibreOffice 26.8.1); the real-conversion tests skip where LibreOffice isn't installed, CI included." [[feature]] id = "create.create-multiple"