From 0f99608112988b3ecb231ae85d88009ccb7872ce Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:53:11 +1030 Subject: [PATCH 01/43] Contributors: credit helenkwok as Helen Kwok --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index fecfb68c90..8ab196abc9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -183,3 +183,6 @@ sync_github_name = true [people.Combiler01] sync_github_name = true + +[people.helenkwok] +real_name = "Helen Kwok" From d5b8a352487bbbaef15d3d586b96394b6300f74a Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:53:11 +1030 Subject: [PATCH 02/43] feat(web): page host mode, so a page can host PdfCraft in its own document (#4731) data-host="page" on the canvas opts a page that loads the module into its own document into the host bridge. The ?host=parent messages travel as non-bubbling CustomEvents on the canvas; host::Bridge holds a Transport (Parent or Page) with one send, and save, dirty, ready and failure reporting are shared. ?host=parent is checked first and unchanged. Hosting guide documents it. --- ROADMAP.md | 2 + apps/pdfcraft-web/Cargo.toml | 2 +- apps/pdfcraft-web/src/main.rs | 240 ++++++++++++++++++++++++---------- packaging/web/README.md | 48 +++++++ 4 files changed, 224 insertions(+), 68 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1f753c500f..f82d8c13a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ > **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (web: same-document host mode on the canvas, `data-host="page"`; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -271,6 +272,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged. - **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged. - **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged. +- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; anything else, including `?host=parent` unframed, keeps the normal app with downloads. It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index ad8243d594..83ef1ca9f0 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -16,5 +16,5 @@ egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget", "CustomEvent", "CustomEventInit"] } js-sys = "0.3" diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index 73d14f4a6c..91da171fba 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -5,18 +5,11 @@ #[cfg(target_arch = "wasm32")] fn main() { - use eframe::wasm_bindgen::JsCast; use pdfcraft_ui_egui::PdfCraftApp; let options = eframe::WebOptions { renderer: eframe::Renderer::Glow, ..Default::default() }; wasm_bindgen_futures::spawn_local(async move { - let Some(canvas) = web_sys::window() - .and_then(|w| w.document()) - .and_then(|d| d.get_element_by_id("pdfcraft")) - .and_then(|e| e.dyn_into::().ok()) - else { - return; - }; + let Some(canvas) = app_canvas() else { return }; let started = eframe::WebRunner::new() .start( canvas, @@ -54,9 +47,10 @@ fn main() { // eframe never raises a close request on a browser reload or tab close, so `guard_quit` never // prompts; ask the browser to confirm instead while a document has unsaved work (#812). warn_before_unload(app.unsaved_flag.clone()); - // `?host=parent`: a same-origin page embedding PdfCraft in an iframe (such - // as the Nextcloud app) passes files in and takes saves back (`host` below). - if query_param("host").as_deref() == Some("parent") + // Host modes (`host` below): `?host=parent`, a same-origin page embedding + // PdfCraft in an iframe (such as the Nextcloud app), or `data-host="page"` + // on the canvas, a host that runs the module in its own document. + if host_requested() && let Some(bridge) = host::Bridge::new() { // `?author=`: the host's name for the user signs their comments, @@ -81,7 +75,7 @@ fn main() { .await; if let Err(e) = started { eframe::web_sys::console::error_1(&e); - if query_param("host").as_deref() == Some("parent") + if host_requested() && let Some(bridge) = host::Bridge::new() { bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}"))); @@ -123,8 +117,37 @@ async fn fetch_bytes(url: &str) -> Result, String> { Ok(js_sys::Uint8Array::new(&buf).to_vec()) } -/// The `postMessage` bridge to a same-origin parent page. Messages are plain objects with a -/// `type`: +/// The app's canvas: the one element `index.html` and every embedding page must provide, with +/// the id the app looks up. +#[cfg(target_arch = "wasm32")] +fn app_canvas() -> Option { + use eframe::wasm_bindgen::JsCast; + web_sys::window()?.document()?.get_element_by_id("pdfcraft").and_then(|e| e.dyn_into::().ok()) +} + +/// Whether a host mode was asked for: `?host=parent` (the iframe bridge) or +/// `data-host="page"` on the canvas (the host document itself). Whether a bridge can actually +/// be built is [`host::Bridge::new`]'s answer. +#[cfg(target_arch = "wasm32")] +fn host_requested() -> bool { + query_param("host").as_deref() == Some("parent") || page_host_enabled(app_canvas().and_then(|c| c.get_attribute("data-host")).as_deref()) +} + +/// Host mode on the canvas is opt-in by attribute value: only the exact `page` turns it on +/// (`data-host="page"`). Everything else — `Page`, ` page`, `parent` (the URL opt-in), empty +/// or missing — keeps the normal app with downloads. +#[cfg(any(target_arch = "wasm32", test))] +fn page_host_enabled(data_host: Option<&str>) -> bool { + data_host == Some("page") +} + +/// Host modes: how the page embedding PdfCraft passes files in and takes saves back. Both are +/// opt-in and carry the same messages; only the transport differs, so only how an event goes +/// out and how `open` comes in change. +/// +/// `?host=parent` — the `postMessage` bridge for a same-origin page that embeds PdfCraft in an +/// iframe (such as the Nextcloud app). Messages are plain objects with a `type`, posted to the +/// parent window: /// /// - `pdfcraft:ready` (to the parent): the app is listening; send files now. /// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or @@ -136,23 +159,52 @@ async fn fetch_bytes(url: &str) -> Result, String> { /// /// Only the parent window on the app's own origin is heard or answered. With a parent on /// another origin there is no bridge: the app runs as if unframed and saves download. +/// +/// `data-host="page"` on the app's canvas — for a host +/// that loads the wasm-bindgen module into its own document and renders on one of its own +/// canvases, with no iframe. The same messages travel as DOM `CustomEvent`s dispatched on that +/// canvas: the event name carries the `type`, and `detail` holds the same fields without it +/// (`pdfcraft:ready` carries `version`, `pdfcraft:save` carries `name`, `saveAs` and a `bytes` +/// `ArrayBuffer`). The host opens a file by dispatching a `pdfcraft:open` `CustomEvent` on the +/// canvas with `detail: { name, bytes }`. Events do not bubble, so nothing outside the canvas +/// hears them, and the canvas belongs to the document that loaded the module, so there is no +/// origin check. An unframed page that asks for `?host=parent` still gets no host mode: only +/// the exact attribute value `page` opts in. #[cfg(target_arch = "wasm32")] mod host { use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure}; + use super::page_host_enabled; + + #[derive(Clone)] + enum Transport { + /// A same-origin parent frame: send by `postMessage` with the origin, listen for + /// `message` events on the window. + Parent { window: web_sys::Window, parent: web_sys::Window, origin: String }, + /// The host document itself: send by dispatching a `CustomEvent` on the app's canvas, + /// and listen on it for `pdfcraft:open`. + Page { canvas: web_sys::HtmlCanvasElement }, + } + #[derive(Clone)] pub struct Bridge { - window: web_sys::Window, - parent: web_sys::Window, - origin: String, + transport: Transport, } impl Bridge { - /// `None` when the page isn't framed (there is no parent to talk to), or when the parent - /// is on another origin: messages only go to this origin, so the browser would drop - /// every save without an error while the document showed as saved (#2167). + /// Picks the transport: `?host=parent` needs a framing page with a same-origin parent; + /// otherwise `data-host="page"` on the app's canvas opts the host document itself in. `None` when the parent is on another origin (or when + /// `?host=parent` is used unframed): messages only go to this origin, so the browser + /// would drop every save without an error while the document showed as saved (#2167). pub fn new() -> Option { let window = web_sys::window()?; + let canvas = super::app_canvas(); + if super::query_param("host").as_deref() != Some("parent") { + if page_host_enabled(canvas.as_ref().and_then(|c| c.get_attribute("data-host")).as_deref()) { + return Some(Self { transport: Transport::Page { canvas: canvas? } }); + } + return None; + } let parent = window.parent().ok().flatten()?; if js_sys::Object::is(&parent, &window) { return None; @@ -162,59 +214,83 @@ mod host { if parent.location().origin().ok()? != origin { return None; } - Some(Self { window, parent, origin }) + Some(Self { transport: Transport::Parent { window, parent, origin } }) } - fn post(&self, message: &js_sys::Object, transfer: Option<&JsValue>) -> Result<(), String> { - let sent = match transfer { - Some(t) => self.parent.post_message_with_transfer(message, &self.origin, &js_sys::Array::of1(t)), - None => self.parent.post_message(message, &self.origin), - }; - sent.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}"))) + /// One outbound message, whatever the transport. `fields` is the payload: the parent + /// transport wraps it with a `type` in the posted object, the page transport puts it in + /// the event's `detail` as-is (the event name carries the type). `transfer` hands an + /// `ArrayBuffer` to the receiver instead of copying it (parent transport). + fn send(&self, name: &str, fields: &[(&str, JsValue)], transfer: Option<&JsValue>) -> Result<(), String> { + match &self.transport { + Transport::Parent { parent, origin, .. } => { + let mut all = Vec::with_capacity(fields.len().saturating_add(1)); + all.push(("type", JsValue::from_str(name))); + all.extend_from_slice(fields); + let Some(m) = message(&all) else { return Err("couldn't build the message".to_string()) }; + let sent = match transfer { + Some(t) => parent.post_message_with_transfer(&m, origin, &js_sys::Array::of1(t)), + None => parent.post_message(&m, origin), + }; + sent.map_err(|e| js_error(&e)) + } + Transport::Page { canvas } => { + let Some(detail) = message(fields) else { return Err("couldn't build the message".to_string()) }; + let init = web_sys::CustomEventInit::new(); + // The builder methods (`bubbles(..)`, `detail(..)`) are deprecated in + // web-sys 0.3.106 and fail clippy -D warnings; use the setters. + init.set_bubbles(false); + init.set_detail(&detail); + let event = web_sys::CustomEvent::new_with_event_init_dict(name, &init).map_err(|e| js_error(&e))?; + // The bool says whether a listener called `preventDefault`; not a send failure. + canvas.dispatch_event(&event).map(|_| ()).map_err(|e| js_error(&e))?; + Ok(()) + } + } } pub fn post_ready(&self) { - if let Some(m) = message(&[("type", "pdfcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]) { - let _ = self.post(&m, None); - } + let _ = self.send("pdfcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None); } - /// The app couldn't start (no WebGL, for example): let the parent say so. + /// The app couldn't start (no WebGL, for example): let the host say so. pub fn post_failed(&self, error: &str) { - if let Some(m) = message(&[("type", "pdfcraft:failed".into()), ("error", error.into())]) { - let _ = self.post(&m, None); - } + let _ = self.send("pdfcraft:failed", &[("error", error.into())], None); } - /// Hear `pdfcraft:open` from the parent and queue the file like a `?file=` download. + /// Hear `pdfcraft:open` — from the same-origin parent (a `message` on the window) or + /// from the host document (a `pdfcraft:open` on the canvas) — and queue the file like a + /// `?file=` download. pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) { - let parent = self.parent.clone(); - let origin = self.origin.clone(); - let on_message = Closure::::new(move |event: web_sys::MessageEvent| { - if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) { - return; + match &self.transport { + Transport::Parent { window, parent, origin } => { + let parent = parent.clone(); + let origin = origin.clone(); + let on_message = Closure::::new(move |event: web_sys::MessageEvent| { + if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) { + return; + } + let data = event.data(); + if string(&data, "type").as_deref() != Some("pdfcraft:open") { + return; + } + queue_open(&data, &inbox, &ctx); + }); + if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() { + // The listener lives as long as the page. + on_message.forget(); + } } - let data = event.data(); - if string(&data, "type").as_deref() != Some("pdfcraft:open") { - return; + Transport::Page { canvas } => { + let on_open = Closure::::new(move |event: web_sys::Event| { + let Some(open) = event.dyn_ref::() else { return }; + queue_open(&open.detail(), &inbox, &ctx); + }); + if canvas.add_event_listener_with_callback("pdfcraft:open", on_open.as_ref().unchecked_ref()).is_ok() { + // The listener lives as long as the page. + on_open.forget(); + } } - let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string()); - let Ok(bytes) = js_sys::Reflect::get(&data, &JsValue::from_str("bytes")) else { return }; - let bytes = if let Some(array) = bytes.dyn_ref::() { - array.to_vec() - } else if bytes.is_instance_of::() { - js_sys::Uint8Array::new(&bytes).to_vec() - } else { - return; - }; - if let Ok(mut q) = inbox.lock() { - q.push((name, bytes)); - } - ctx.request_repaint(); - }); - if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() { - // The listener lives as long as the page. - on_message.forget(); } } @@ -222,10 +298,7 @@ mod host { let bridge = self.clone(); Box::new(move |name: &str, bytes: &[u8], save_as: bool| { let buffer = js_sys::Uint8Array::from(bytes).buffer(); - let m = - message(&[("type", "pdfcraft:save".into()), ("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())]) - .ok_or_else(|| "couldn't build the message".to_string())?; - bridge.post(&m, Some(&buffer)) + bridge.send("pdfcraft:save", &[("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())], Some(&buffer)) }) } @@ -235,14 +308,30 @@ mod host { Box::new(move |dirty: bool| { if dirty != last { last = dirty; - if let Some(m) = message(&[("type", "pdfcraft:dirty".into()), ("dirty", dirty.into())]) { - let _ = bridge.post(&m, None); - } + let _ = bridge.send("pdfcraft:dirty", &[("dirty", dirty.into())], None); } }) } } + /// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` is an `ArrayBuffer` or + /// `Uint8Array` holding a PDF: queue it like a `?file=` download and wake the app. + fn queue_open(data: &JsValue, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) { + let name = string(data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string()); + let Ok(bytes) = js_sys::Reflect::get(data, &JsValue::from_str("bytes")) else { return }; + let bytes = if let Some(array) = bytes.dyn_ref::() { + array.to_vec() + } else if bytes.is_instance_of::() { + js_sys::Uint8Array::new(&bytes).to_vec() + } else { + return; + }; + if let Ok(mut q) = inbox.lock() { + q.push((name, bytes)); + } + ctx.request_repaint(); + } + fn message(fields: &[(&str, JsValue)]) -> Option { let m = js_sys::Object::new(); for (key, value) in fields { @@ -254,9 +343,26 @@ mod host { fn string(data: &JsValue, key: &str) -> Option { js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string() } + + fn js_error(value: &JsValue) -> String { + value.as_string().unwrap_or_else(|| format!("{value:?}")) + } } #[cfg(not(target_arch = "wasm32"))] fn main() { eprintln!("pdfcraft-web targets wasm32: run `trunk serve` or `trunk build --release` in apps/pdfcraft-web"); } + +#[cfg(test)] +mod page_host_tests { + use super::page_host_enabled; + + #[test] + fn only_the_exact_value_page_enables_the_page_host_mode() { + assert!(page_host_enabled(Some("page"))); + for off in [None, Some(""), Some("Page"), Some("PAGE"), Some(" page"), Some("page "), Some("parent")] { + assert!(!page_host_enabled(off), "{off:?} must keep host mode off"); + } + } +} diff --git a/packaging/web/README.md b/packaging/web/README.md index 7aaa49d6a5..9e32c3602e 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -78,6 +78,54 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo downloads) are blocked. - Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page. +## Same-document host mode (no iframe) + +A host application can also run PdfCraft from its own document: it loads the wasm-bindgen module +itself and gives PdfCraft one of its own canvases, so there is no iframe and no `?host=parent`. +It opts in with `data-host="page"` on that canvas — the exact value `page` — and the canvas +keeps the id the app looks up (`id="pdfcraft"`). + +The page and the app then talk with DOM `CustomEvent`s on the canvas instead of `postMessage`. +The app dispatches the same messages it would post to a parent — `pdfcraft:ready`, +`pdfcraft:save`, `pdfcraft:dirty`, `pdfcraft:failed` — with the message fields (without the +`type`) as the event's `detail`, and it opens a file when the page dispatches a `pdfcraft:open` +event with `detail: { name, bytes }`. Events do not bubble, so listen on the canvas itself. + +Add the listeners before `init`: the app announces itself with `pdfcraft:ready` while `init` is +still running, and a file sent in reply would otherwise be missed. + +```js +import init from "./pdfcraft-web-.js"; // the wasm-bindgen glue shipped with the app + +const canvas = document.getElementById("pdfcraft"); +canvas.dataset.host = "page"; // or + +canvas.addEventListener("pdfcraft:ready", () => { + // The app is listening; hand it a document now, if you have one. + canvas.dispatchEvent(new CustomEvent("pdfcraft:open", { + detail: { name: "handout.pdf", bytes: pdfBytes }, // ArrayBuffer or Uint8Array + })); +}); +canvas.addEventListener("pdfcraft:save", (event) => { + const { name, saveAs, bytes } = event.detail; // bytes is an ArrayBuffer + // Store the file under `name`; `saveAs` is true for Save As. Report your own errors. +}); +canvas.addEventListener("pdfcraft:dirty", (event) => { + unsaved = event.detail.dirty; // warn before leaving, as you like +}); +canvas.addEventListener("pdfcraft:failed", (event) => { + showError(event.detail.error); // the app couldn't start +}); + +await init({ module_or_path: "./pdfcraft-web-_bg.wasm" }); +``` + +- Saves are not downloads in this mode: the `bytes` in `pdfcraft:save` hold the saved PDF, and + the host decides where the file goes. +- `?author=` still signs the user's comments, as with `?host=parent`. +- An unframed page that uses `?host=parent` keeps the normal app with downloads; only + `data-host="page"` turns this mode on. + ## Renderer selection and fallback flags PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to From 5574a8bf4af089ce96d7188713a63885e4114738 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:48:41 +0800 Subject: [PATCH 03/43] ROADMAP: a framed ?host=parent selects the parent bridge; only other requests keep downloads --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index f82d8c13a6..f2d886c3b8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -272,7 +272,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged. - **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged. - **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged. -- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; anything else, including `?host=parent` unframed, keeps the normal app with downloads. It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes. +- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. From 43efa9a9e39c7971cedbc95bfdc4f2c1a2c79b25 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:04:08 +0800 Subject: [PATCH 04/43] packaging/web: say that ?host=parent takes precedence over data-host="page" --- packaging/web/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packaging/web/README.md b/packaging/web/README.md index 9e32c3602e..728c1a20ae 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -123,8 +123,10 @@ await init({ module_or_path: "./pdfcraft-web-_bg.wasm" }); - Saves are not downloads in this mode: the `bytes` in `pdfcraft:save` hold the saved PDF, and the host decides where the file goes. - `?author=` still signs the user's comments, as with `?host=parent`. -- An unframed page that uses `?host=parent` keeps the normal app with downloads; only - `data-host="page"` turns this mode on. +- An unframed page that uses `?host=parent` keeps the normal app with downloads. + `data-host="page"` turns this mode on only when `?host=parent` is absent: if `?host=parent` is + present it takes precedence, so a same-origin framed page uses the parent bridge, while an + unframed or cross-origin page gets no bridge and keeps normal downloads. ## Renderer selection and fallback flags From c79e48d6088e0ed1b78a53a7aee24a69cf2b4203 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:30:54 +0800 Subject: [PATCH 05/43] web host mode: refuse an oversized host file before copying it, cap the name, and document trust and limits --- apps/pdfcraft-web/src/main.rs | 27 +++++++++++++++++++++++---- packaging/web/README.md | 6 ++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index 91da171fba..2ff9874d5f 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -314,18 +314,37 @@ mod host { } } + /// The largest file the host may hand over to open, checked before it is copied (the app's own readers have their own limits). + const MAX_OPEN_BYTES: u64 = 512 << 20; + + /// The longest file name taken from the host. + const MAX_NAME_CHARS: usize = 255; + /// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` is an `ArrayBuffer` or /// `Uint8Array` holding a PDF: queue it like a `?file=` download and wake the app. fn queue_open(data: &JsValue, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) { - let name = string(data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string()); + let name: String = string(data, "name") + .filter(|n| !n.trim().is_empty()) + .unwrap_or_else(|| "document.pdf".to_string()) + .chars() + .take(MAX_NAME_CHARS) + .collect(); let Ok(bytes) = js_sys::Reflect::get(data, &JsValue::from_str("bytes")) else { return }; - let bytes = if let Some(array) = bytes.dyn_ref::() { - array.to_vec() + let view = if let Some(array) = bytes.dyn_ref::() { + array.clone() } else if bytes.is_instance_of::() { - js_sys::Uint8Array::new(&bytes).to_vec() + js_sys::Uint8Array::new(&bytes) } else { return; }; + // A file from the host is untrusted input: refuse an oversized one before copying it into the app's memory. + if u64::from(view.length()) > MAX_OPEN_BYTES { + eframe::web_sys::console::warn_1( + &format!("PdfCraft: host file is {} bytes, over the {} MiB host open limit; not opened", view.length(), MAX_OPEN_BYTES >> 20).into(), + ); + return; + } + let bytes = view.to_vec(); if let Ok(mut q) = inbox.lock() { q.push((name, bytes)); } diff --git a/packaging/web/README.md b/packaging/web/README.md index 728c1a20ae..64552b1252 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -128,6 +128,12 @@ await init({ module_or_path: "./pdfcraft-web-_bg.wasm" }); present it takes precedence, so a same-origin framed page uses the parent bridge, while an unframed or cross-origin page gets no bridge and keeps normal downloads. +### Trust and limits + +- **Who is trusted.** In page host mode the page and PdfCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin. +- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters. +- **The event and message names are the interface.** `pdfcraft:ready`, `pdfcraft:open`, `pdfcraft:save`, `pdfcraft:dirty` and `pdfcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change. + ## Renderer selection and fallback flags PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to From 3df903d516d2dfb3e00103c4960d2cbf9e32752d Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:11:28 +0800 Subject: [PATCH 06/43] pdfcraft-web: speak the host messages through craft-web-host The transports, the message types and the origin checks come from the shared crate (storytold/craft-libs#420); PdfCraft keeps the save and dirty hooks, the open size limit and the author. --- Cargo.lock | 10 ++ apps/pdfcraft-web/Cargo.toml | 2 + apps/pdfcraft-web/src/main.rs | 328 +++++++++------------------------- packaging/web/README.md | 2 + 4 files changed, 101 insertions(+), 241 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 573b215b7c..1bf064f5d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1303,6 +1303,15 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-web-host" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" @@ -4729,6 +4738,7 @@ dependencies = [ name = "pdfcraft-web" version = "0.18.1" dependencies = [ + "craft-web-host", "eframe", "egui", "js-sys", diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index 83ef1ca9f0..56e10c0c29 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -11,6 +11,8 @@ workspace = true [dependencies] pdfcraft-ui-egui = { workspace = true } +# The host messages (storytold/craft-libs): one contract and one bridge for the web builds. +craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" } eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] } egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index 2ff9874d5f..ffa5a06068 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -47,12 +47,11 @@ fn main() { // eframe never raises a close request on a browser reload or tab close, so `guard_quit` never // prompts; ask the browser to confirm instead while a document has unsaved work (#812). warn_before_unload(app.unsaved_flag.clone()); - // Host modes (`host` below): `?host=parent`, a same-origin page embedding - // PdfCraft in an iframe (such as the Nextcloud app), or `data-host="page"` - // on the canvas, a host that runs the module in its own document. - if host_requested() - && let Some(bridge) = host::Bridge::new() - { + // Host modes (`host` below, over `craft-web-host`): `?host=parent`, a + // same-origin page embedding PdfCraft in an iframe (such as the Nextcloud + // app), or `data-host="page"` on the canvas, a host that runs the module in + // its own document. + if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) { // `?author=`: the host's name for the user signs their comments, // unless they chose a name in PdfCraft (the web default is "Guest"). if let Some(author) = query_param("author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()) @@ -75,9 +74,7 @@ fn main() { .await; if let Err(e) = started { eframe::web_sys::console::error_1(&e); - if host_requested() - && let Some(bridge) = host::Bridge::new() - { + if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) { bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}"))); } } @@ -125,263 +122,112 @@ fn app_canvas() -> Option { web_sys::window()?.document()?.get_element_by_id("pdfcraft").and_then(|e| e.dyn_into::().ok()) } -/// Whether a host mode was asked for: `?host=parent` (the iframe bridge) or -/// `data-host="page"` on the canvas (the host document itself). Whether a bridge can actually -/// be built is [`host::Bridge::new`]'s answer. -#[cfg(target_arch = "wasm32")] -fn host_requested() -> bool { - query_param("host").as_deref() == Some("parent") || page_host_enabled(app_canvas().and_then(|c| c.get_attribute("data-host")).as_deref()) -} - -/// Host mode on the canvas is opt-in by attribute value: only the exact `page` turns it on -/// (`data-host="page"`). Everything else — `Page`, ` page`, `parent` (the URL opt-in), empty -/// or missing — keeps the normal app with downloads. -#[cfg(any(target_arch = "wasm32", test))] -fn page_host_enabled(data_host: Option<&str>) -> bool { - data_host == Some("page") -} - -/// Host modes: how the page embedding PdfCraft passes files in and takes saves back. Both are -/// opt-in and carry the same messages; only the transport differs, so only how an event goes -/// out and how `open` comes in change. +/// Host modes: how the page embedding PdfCraft passes files in and takes saves back. The messages +/// and the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the +/// other Craft apps' bridges, with the `pdfcraft:` prefix. This module is PdfCraft's part of it. /// -/// `?host=parent` — the `postMessage` bridge for a same-origin page that embeds PdfCraft in an -/// iframe (such as the Nextcloud app). Messages are plain objects with a `type`, posted to the -/// parent window: -/// -/// - `pdfcraft:ready` (to the parent): the app is listening; send files now. -/// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or -/// `Uint8Array` holding a PDF. It opens in a tab, like a `?file=` URL. -/// - `pdfcraft:save` (to the parent): `{ name, saveAs, bytes }` when the user saves; `bytes` is -/// a transferred `ArrayBuffer`. The parent stores the file (and reports its own errors). -/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away. -/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start. -/// -/// Only the parent window on the app's own origin is heard or answered. With a parent on -/// another origin there is no bridge: the app runs as if unframed and saves download. -/// -/// `data-host="page"` on the app's canvas — for a host -/// that loads the wasm-bindgen module into its own document and renders on one of its own -/// canvases, with no iframe. The same messages travel as DOM `CustomEvent`s dispatched on that -/// canvas: the event name carries the `type`, and `detail` holds the same fields without it -/// (`pdfcraft:ready` carries `version`, `pdfcraft:save` carries `name`, `saveAs` and a `bytes` -/// `ArrayBuffer`). The host opens a file by dispatching a `pdfcraft:open` `CustomEvent` on the -/// canvas with `detail: { name, bytes }`. Events do not bubble, so nothing outside the canvas -/// hears them, and the canvas belongs to the document that loaded the module, so there is no -/// origin check. An unframed page that asks for `?host=parent` still gets no host mode: only -/// the exact attribute value `page` opts in. +/// - `?host=parent` is the `postMessage` bridge for a same-origin page that embeds PdfCraft in an +/// iframe (such as the Nextcloud app). With a parent on another origin there is no bridge: the +/// app runs as if unframed and saves download (#2167). `data-host="page"` on the app's canvas +/// is for a host that loads the module into its own document, with no iframe: the same messages +/// travel as DOM `CustomEvent`s on that canvas. `?host=parent` wins when both are present. +/// - `pdfcraft:ready` once the app is listening; send files now. PdfCraft reads bytes, so it +/// says `file: false` and a host sends `bytes`. +/// - `pdfcraft:open` (from the host): `{ name, bytes }` holding a PDF. It opens in a tab, like a +/// `?file=` URL; a file over [`host::MAX_OPEN_BYTES`] is refused before it is copied. +/// - `pdfcraft:save` (to the host): `{ name, saveAs, bytes }` when the user saves. The host stores +/// the file (and reports its own errors). +/// - `pdfcraft:dirty` whenever unsaved work appears or goes away, `pdfcraft:failed` when the app +/// couldn't start. #[cfg(target_arch = "wasm32")] mod host { - use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure}; + use std::rc::Rc; - use super::page_host_enabled; + use craft_web_host::web::{FileData, Host, Message}; - #[derive(Clone)] - enum Transport { - /// A same-origin parent frame: send by `postMessage` with the origin, listen for - /// `message` events on the window. - Parent { window: web_sys::Window, parent: web_sys::Window, origin: String }, - /// The host document itself: send by dispatching a `CustomEvent` on the app's canvas, - /// and listen on it for `pdfcraft:open`. - Page { canvas: web_sys::HtmlCanvasElement }, - } + /// The app id in the host messages (`pdfcraft:ready`). + const APP: &str = "pdfcraft"; - #[derive(Clone)] - pub struct Bridge { - transport: Transport, - } - - impl Bridge { - /// Picks the transport: `?host=parent` needs a framing page with a same-origin parent; - /// otherwise `data-host="page"` on the app's canvas opts the host document itself in. `None` when the parent is on another origin (or when - /// `?host=parent` is used unframed): messages only go to this origin, so the browser - /// would drop every save without an error while the document showed as saved (#2167). - pub fn new() -> Option { - let window = web_sys::window()?; - let canvas = super::app_canvas(); - if super::query_param("host").as_deref() != Some("parent") { - if page_host_enabled(canvas.as_ref().and_then(|c| c.get_attribute("data-host")).as_deref()) { - return Some(Self { transport: Transport::Page { canvas: canvas? } }); - } - return None; - } - let parent = window.parent().ok().flatten()?; - if js_sys::Object::is(&parent, &window) { - return None; - } - let origin = window.location().origin().ok()?; - // Reading another origin's location throws, which is `None` too. - if parent.location().origin().ok()? != origin { - return None; - } - Some(Self { transport: Transport::Parent { window, parent, origin } }) - } - - /// One outbound message, whatever the transport. `fields` is the payload: the parent - /// transport wraps it with a `type` in the posted object, the page transport puts it in - /// the event's `detail` as-is (the event name carries the type). `transfer` hands an - /// `ArrayBuffer` to the receiver instead of copying it (parent transport). - fn send(&self, name: &str, fields: &[(&str, JsValue)], transfer: Option<&JsValue>) -> Result<(), String> { - match &self.transport { - Transport::Parent { parent, origin, .. } => { - let mut all = Vec::with_capacity(fields.len().saturating_add(1)); - all.push(("type", JsValue::from_str(name))); - all.extend_from_slice(fields); - let Some(m) = message(&all) else { return Err("couldn't build the message".to_string()) }; - let sent = match transfer { - Some(t) => parent.post_message_with_transfer(&m, origin, &js_sys::Array::of1(t)), - None => parent.post_message(&m, origin), - }; - sent.map_err(|e| js_error(&e)) - } - Transport::Page { canvas } => { - let Some(detail) = message(fields) else { return Err("couldn't build the message".to_string()) }; - let init = web_sys::CustomEventInit::new(); - // The builder methods (`bubbles(..)`, `detail(..)`) are deprecated in - // web-sys 0.3.106 and fail clippy -D warnings; use the setters. - init.set_bubbles(false); - init.set_detail(&detail); - let event = web_sys::CustomEvent::new_with_event_init_dict(name, &init).map_err(|e| js_error(&e))?; - // The bool says whether a listener called `preventDefault`; not a send failure. - canvas.dispatch_event(&event).map(|_| ()).map_err(|e| js_error(&e))?; - Ok(()) - } - } - } - - pub fn post_ready(&self) { - let _ = self.send("pdfcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None); - } - - /// The app couldn't start (no WebGL, for example): let the host say so. - pub fn post_failed(&self, error: &str) { - let _ = self.send("pdfcraft:failed", &[("error", error.into())], None); - } - - /// Hear `pdfcraft:open` — from the same-origin parent (a `message` on the window) or - /// from the host document (a `pdfcraft:open` on the canvas) — and queue the file like a - /// `?file=` download. - pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) { - match &self.transport { - Transport::Parent { window, parent, origin } => { - let parent = parent.clone(); - let origin = origin.clone(); - let on_message = Closure::::new(move |event: web_sys::MessageEvent| { - if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) { - return; - } - let data = event.data(); - if string(&data, "type").as_deref() != Some("pdfcraft:open") { - return; - } - queue_open(&data, &inbox, &ctx); - }); - if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() { - // The listener lives as long as the page. - on_message.forget(); - } - } - Transport::Page { canvas } => { - let on_open = Closure::::new(move |event: web_sys::Event| { - let Some(open) = event.dyn_ref::() else { return }; - queue_open(&open.detail(), &inbox, &ctx); - }); - if canvas.add_event_listener_with_callback("pdfcraft:open", on_open.as_ref().unchecked_ref()).is_ok() { - // The listener lives as long as the page. - on_open.forget(); - } - } - } - } - - pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn { - let bridge = self.clone(); - Box::new(move |name: &str, bytes: &[u8], save_as: bool| { - let buffer = js_sys::Uint8Array::from(bytes).buffer(); - bridge.send("pdfcraft:save", &[("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())], Some(&buffer)) - }) - } - - pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn { - let bridge = self.clone(); - let mut last = false; - Box::new(move |dirty: bool| { - if dirty != last { - last = dirty; - let _ = bridge.send("pdfcraft:dirty", &[("dirty", dirty.into())], None); - } - }) - } - } + /// The name a file gets when the host sent none (the crate says `document` for a blank name). + const DEFAULT_NAME: &str = "document.pdf"; /// The largest file the host may hand over to open, checked before it is copied (the app's own readers have their own limits). const MAX_OPEN_BYTES: u64 = 512 << 20; - /// The longest file name taken from the host. - const MAX_NAME_CHARS: usize = 255; + #[derive(Clone)] + pub struct Bridge { + host: Rc, + } - /// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` is an `ArrayBuffer` or - /// `Uint8Array` holding a PDF: queue it like a `?file=` download and wake the app. - fn queue_open(data: &JsValue, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) { - let name: String = string(data, "name") - .filter(|n| !n.trim().is_empty()) - .unwrap_or_else(|| "document.pdf".to_string()) - .chars() - .take(MAX_NAME_CHARS) - .collect(); - let Ok(bytes) = js_sys::Reflect::get(data, &JsValue::from_str("bytes")) else { return }; - let view = if let Some(array) = bytes.dyn_ref::() { - array.clone() - } else if bytes.is_instance_of::() { - js_sys::Uint8Array::new(&bytes) - } else { + impl Bridge { + /// The bridge for this page, or `None` when PdfCraft isn't hosted: no `?host=parent` and + /// no `data-host="page"` on the canvas, an unframed `?host=parent` page, or a parent on + /// another origin (messages only go to this origin, so the browser would drop every save + /// without an error while the document showed as saved, #2167). + pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option { + Some(Self { host: Rc::new(Host::connect(APP, canvas)?) }) + } + + pub fn post_ready(&self) { + let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false); + } + + /// The app couldn't start (no WebGL, for example): let the host say so. + pub fn post_failed(&self, error: &str) { + let _ = self.host.post_failed(error, None); + } + + /// Hear `pdfcraft:open` and queue the file like a `?file=` download. + pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) { + self.host.listen(move |message| { + if let Message::Open { name, data } = message { + queue_open(&name, &data, &inbox, &ctx); + } + }); + } + + pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn { + let host = self.host.clone(); + Box::new(move |name: &str, bytes: &[u8], save_as: bool| host.post_save(name, bytes, None, save_as)) + } + + pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn { + let host = self.host.clone(); + // A host starts out clean: the first report is a change only when it is dirty. + let mut last = false; + Box::new(move |dirty: bool| { + if dirty != last { + last = dirty; + host.post_dirty(dirty); + } + }) + } + } + + /// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` holds a PDF: queue it like a + /// `?file=` download and wake the app. The crate has cleaned the name (and cut it to 255 + /// characters); a blank one comes as `document` and becomes `document.pdf`. + fn queue_open(name: &str, data: &FileData, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) { + let name = if name == "document" { DEFAULT_NAME } else { name }.to_string(); + let FileData::Bytes(view) = data else { + eframe::web_sys::console::warn_1(&format!("PdfCraft: host file {name} came as a File; PdfCraft reads bytes only; not opened").into()); return; }; // A file from the host is untrusted input: refuse an oversized one before copying it into the app's memory. - if u64::from(view.length()) > MAX_OPEN_BYTES { + if data.size() > MAX_OPEN_BYTES as f64 { eframe::web_sys::console::warn_1( - &format!("PdfCraft: host file is {} bytes, over the {} MiB host open limit; not opened", view.length(), MAX_OPEN_BYTES >> 20).into(), + &format!("PdfCraft: host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20).into(), ); return; } - let bytes = view.to_vec(); if let Ok(mut q) = inbox.lock() { - q.push((name, bytes)); + q.push((name, view.to_vec())); } ctx.request_repaint(); } - - fn message(fields: &[(&str, JsValue)]) -> Option { - let m = js_sys::Object::new(); - for (key, value) in fields { - js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?; - } - Some(m) - } - - fn string(data: &JsValue, key: &str) -> Option { - js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string() - } - - fn js_error(value: &JsValue) -> String { - value.as_string().unwrap_or_else(|| format!("{value:?}")) - } } #[cfg(not(target_arch = "wasm32"))] fn main() { eprintln!("pdfcraft-web targets wasm32: run `trunk serve` or `trunk build --release` in apps/pdfcraft-web"); } - -#[cfg(test)] -mod page_host_tests { - use super::page_host_enabled; - - #[test] - fn only_the_exact_value_page_enables_the_page_host_mode() { - assert!(page_host_enabled(Some("page"))); - for off in [None, Some(""), Some("Page"), Some("PAGE"), Some(" page"), Some("page "), Some("parent")] { - assert!(!page_host_enabled(off), "{off:?} must keep host mode off"); - } - } -} diff --git a/packaging/web/README.md b/packaging/web/README.md index 64552b1252..3149dc75d5 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -128,6 +128,8 @@ await init({ module_or_path: "./pdfcraft-web-_bg.wasm" }); present it takes precedence, so a same-origin framed page uses the parent bridge, while an unframed or cross-origin page gets no bridge and keeps normal downloads. +The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `pdfcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. PdfCraft reads bytes, so a host sends `bytes`, not a `File`. + ### Trust and limits - **Who is trusted.** In page host mode the page and PdfCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin. From 96fe832e12c6de3ba88005496ba3d57cc3039fa9 Mon Sep 17 00:00:00 2001 From: ohagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:01:18 +0100 Subject: [PATCH 07/43] M5: Shift holds lines level or plumb while drawing (ortho) (#4285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M5: Shift holds lines level or plumb while drawing (ortho) With Shift held, a new line or arrow runs level or plumb from where it starts, whichever is nearer to the pointer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud is held the same way from the corner before, rubber band included. Dragging an end or corner of a drawn one with Shift is held the same way; it turned to 45° steps before. The measuring tools keep their 45° steps. measure::snap::ortho next to constrain; comment_add takes `ortho` for line, arrow, polygon, cloud and polyline. Parity comment.draw-ortho. Co-Authored-By: Claude Opus 5.5 * Ortho: over the first corner the rubber band shows the closing side, Shift or not From review: a click there closes the polygon or cloud to its first corner, while the preview with Shift held showed a level or plumb side. The click and the preview now share one rule (closes_at). Co-Authored-By: Claude Opus 5.5 * Rebase: Shift is read where main's split put the drawing input, and the ortho helpers are imported there Co-Authored-By: Claude Opus 5.5 * Rebase: Shift holds a drawn line, not a freehand stroke (a hunk had landed in the wrong branch) Co-Authored-By: Claude Opus 5.5 * Ortho: the parity note names the closing side's exception; the test's doc comment is back above its test Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- ROADMAP.md | 1 + crates/automation/src/comments.rs | 16 +++- crates/automation/src/tools.rs | 1 + crates/automation/tests/it/automation.rs | 38 +++++++++ crates/measure/src/snap.rs | 6 ++ crates/measure/src/tests.rs | 10 +++ crates/ui-egui/src/tools/comments/geometry.rs | 18 ++-- crates/ui-egui/src/tools/comments/input.rs | 18 ++-- crates/ui-egui/src/tools/comments/paint.rs | 14 +++- crates/ui-egui/tests/it/comments.rs | 83 +++++++++++++++++-- docs/ui-parity.md | 1 + parity/acrobat-features.toml | 14 +++- 12 files changed, 197 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 49f7d4101d..5eaf0f5035 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -302,6 +302,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. - **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. - **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged. +- **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged. - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 135d0868ee..a1e5e05263 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -253,6 +253,12 @@ impl Automation { if endings.is_some() && !matches!(kind, "line" | "arrow" | "polyline" | "callout") { return Err(ToolError::InvalidArgs("endings apply to a line, arrow, polyline or callout".into())); } + // Shift while drawing: each end or corner held level or plumb with the one before. + let ortho = a.opt_bool("ortho")?.unwrap_or(false); + if ortho && !matches!(kind, "line" | "arrow" | "polygon" | "cloud" | "polyline") { + return Err(ToolError::InvalidArgs("ortho applies to a line, arrow, polygon, cloud or polyline".into())); + } + let held = |last: [f64; 2], p: [f64; 2]| if ortho { pdfcraft_engine::measure::snap::ortho(last, p) } else { p }; let markup = match kind { "highlight" => Some(Markup::Highlight), "underline" => Some(Markup::Underline), @@ -325,7 +331,8 @@ impl Automation { Shape::TextBox { rect: rect_to_user(&info, a.need::<4>("rect", "a text box")?), font_size } } "line" | "arrow" => { - let (f, t) = (a.need::<2>("from", "a line")?, a.need::<2>("to", "a line")?); + let f = a.need::<2>("from", "a line")?; + let t = held(f, a.need::<2>("to", "a line")?); let [start, end] = ending_pair(kind, endings.as_deref())?; Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end } } @@ -352,10 +359,15 @@ impl Automation { "polygon" | "cloud" | "polyline" => { let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points")); let pts = a.get("points").and_then(|p| p.as_array()).ok_or_else(wrong)?; + let mut last = None; let vertices = pts .iter() .map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::>()).as_deref() { - Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)), + Some([Some(x), Some(y)]) => { + let p = last.map_or([*x, *y], |l| held(l, [*x, *y])); + last = Some(p); + Ok(to_user(&info, p[0], p[1])) + } _ => Err(wrong()), }) .collect::>>()?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 14b55361c5..0728679c8e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -861,6 +861,7 @@ pub fn tools() -> Vec { "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "width": { "type": "number", "minimum": 0, "description": "Line width in points." }, "endings": { "type": "array", "items": { "type": "string", "enum": ["None", "Square", "Circle", "Diamond", "OpenArrow", "ClosedArrow", "Butt", "ROpenArrow", "RClosedArrow", "Slash"] }, "minItems": 1, "maxItems": 2, "description": "Line endings. Two names (start, end) for a line, arrow or polyline; one name for a callout." }, + "ortho": { "type": "boolean", "description": "line/arrow/polygon/cloud/polyline: hold each end or corner level or plumb with the one before, whichever is nearer (Shift while drawing)." }, "font_size": { "type": "number", "exclusiveMinimum": 0 }, }), &["doc", "page", "type"], diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 35c4db79c2..5bde9d27f9 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6493,6 +6493,44 @@ fn drawing_comments_through_tools() { assert!(matches!(png[0], Content::Png { .. })); } +/// comment_add `ortho`, Shift's hold while drawing: a line's end is held level or plumb with its +/// start, and each corner of connected lines or a polygon with the corner before. +#[test] +fn lines_and_corners_are_held_level_or_plumb_through_the_tool() { + let dir = workdir("ortho"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for args in [ + json!({ "type": "line", "from": [20, 40], "to": [120, 52], "ortho": true }), + json!({ "type": "arrow", "from": [180, 40], "to": [170, 140], "ortho": true }), + json!({ "type": "polyline", "points": [[20, 200], [90, 210], [95, 260], [130, 250]], "ortho": true }), + json!({ "type": "line", "from": [20, 280], "to": [120, 290] }), + ] { + let mut args = args; + args.as_object_mut().unwrap().extend([("doc".to_string(), json!(doc)), ("page".to_string(), json!(1))]); + ok(&mut a, "comment_add", args); + } + let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + // In the order added: the list isn't sorted by index. + let mut listed = list["comments"].as_array().unwrap().clone(); + listed.sort_by_key(|c| c["index"].as_u64()); + let points: Vec> = + listed.iter().map(|c| c["points"].as_array().unwrap().iter().map(|p| [p[0].as_f64().unwrap(), p[1].as_f64().unwrap()]).collect()).collect(); + let want: [&[[f64; 2]]; 4] = [ + &[[20.0, 40.0], [120.0, 40.0]], + &[[180.0, 40.0], [180.0, 140.0]], + &[[20.0, 200.0], [90.0, 200.0], [90.0, 260.0], [130.0, 260.0]], + &[[20.0, 280.0], [120.0, 290.0]], + ]; + assert_eq!(points.len(), want.len(), "{points:?}"); + for (got, want) in points.iter().zip(want) { + assert_eq!(got.len(), want.len(), "{got:?}"); + assert!(got.iter().zip(want).all(|(g, w)| (g[0] - w[0]).abs() < 0.01 && (g[1] - w[1]).abs() < 0.01), "{got:?} for {want:?}"); + } + let refused = a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 50, 50], "ortho": true })); + assert!(matches!(refused, Err(ToolError::InvalidArgs(_))), "ortho is for lines and corners: {refused:?}"); +} + #[test] fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { let dir = workdir("signing-graphic"); diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index 930ffe3053..b26c389e96 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -189,6 +189,12 @@ pub fn constrain(last: Point, point: Point) -> Point { let angle = (v[1].atan2(v[0]) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4; [last[0] + length * angle.cos(), last[1] + length * angle.sin()] } +/// Hold a proposed vertex level or plumb with the last vertex, whichever is nearer (a CAD +/// drawing's ortho mode): the vertex slides onto that axis rather than turning onto it. +pub fn ortho(last: Point, point: Point) -> Point { + let v = sub(point, last); + if v[0].abs() >= v[1].abs() { [point[0], last[1]] } else { [last[0], point[1]] } +} struct Walker<'a> { doc: &'a Document, geometry: Geometry, diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 5b277e5546..88906910c7 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -449,6 +449,16 @@ fn listing_skips_repeated_refs_and_caps_what_it_keeps() { assert!(!listing.measurements.is_empty()); } #[test] +fn ortho_holds_a_vertex_level_or_plumb_with_the_last() { + let last = [10.0, 20.0]; + assert_eq!(snap::ortho(last, [50.0, 27.0]), [50.0, 20.0], "mostly across: level"); + assert_eq!(snap::ortho(last, [-30.0, 13.0]), [-30.0, 20.0], "to the left too"); + assert_eq!(snap::ortho(last, [14.0, 80.0]), [10.0, 80.0], "mostly up: plumb"); + assert_eq!(snap::ortho(last, [8.0, -40.0]), [10.0, -40.0], "and down"); + assert_eq!(snap::ortho(last, [30.0, 40.0]), [30.0, 20.0], "a tie stays level"); + assert_eq!(snap::ortho(last, last), last); +} +#[test] fn a_stream_over_the_byte_budget_uses_it_up_once() { // 50 references to one Flate stream that inflates past the 16 MiB budget decode once. let big = pdfcraft_cos::Stream::flate(Dict::new(), &vec![b' '; 17 << 20]); diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index ee67b92bb0..4f3b338fe6 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -32,15 +32,21 @@ pub(super) fn point_handle(cx: &PageCx<'_>, a: &Annotation, p: Pos2) -> Option, to: [f64; 2], shift: bool) -> [f64; 2] { + match last { + Some(l) if shift => pdfcraft_engine::measure::snap::ortho(*l, to), + _ => to, + } +} + +/// `points` with point `i` moved to `to`; with Shift, held level or plumb with the point before +/// it (a line's other end; the first corner is held to the second). pub(crate) fn reshaped(points: &[[f64; 2]], i: usize, to: [f64; 2], shift: bool) -> Vec<[f64; 2]> { let mut out = points.to_vec(); let anchor = if i == 0 { points.get(1) } else { points.get(i - 1) }; - let to = match anchor { - Some(a) if shift => pdfcraft_engine::measure::snap::constrain(*a, to), - _ => to, - }; + let to = held(anchor, to, shift); if let Some(p) = out.get_mut(i) { *p = to; } diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 23a5ab3f06..caddd3e837 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -1,8 +1,8 @@ use super::geometry::{ - CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, is_markup, point_handle, reaches_points, reshapable, reshaped, resizable, - resized, snapped_caret, + CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, held, is_markup, point_handle, reaches_points, reshapable, reshaped, + resizable, resized, snapped_caret, }; -use super::paint::{drawn_shape, edit_anchor, erase, new_comment}; +use super::paint::{closes_at, drawn_shape, edit_anchor, erase, new_comment}; use super::state::{Composer, ComposerKind, Gesture, PageCx}; use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; use crate::page_cursor::{Layer, Subject}; @@ -346,6 +346,8 @@ impl Gestures<'_> { } fn draw_shape(&self, view: &mut DocView, tool: CommentTool) -> bool { + // Shift holds a line level or plumb (ortho). + let shift = self.resp.ctx.input(|i| i.modifiers.shift); let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { @@ -374,7 +376,8 @@ impl Gestures<'_> { } } else { points.truncate(1); - points.push(u); + let line = matches!(*tool, CommentTool::Line | CommentTool::Arrow); + points.push(held(points.first(), u, line && shift)); } } if resp.drag_stopped() @@ -408,6 +411,8 @@ impl Gestures<'_> { } fn click_points(&self, view: &mut DocView, tool: CommentTool) -> bool { + // Shift holds the next side of connected lines, a polygon or a cloud level or plumb. + let shift = self.resp.ctx.input(|i| i.modifiers.shift); let Self { ui, resp, cx, pointer, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { @@ -434,8 +439,9 @@ impl Gestures<'_> { match cv.gesture.as_mut() { Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => { // Clicking the first point again closes a polygon. - let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0; - let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0); + let closes = closes_at(cx, tool, points, p); + let u = held(points.last(), u, shift); + let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(cx.to_screen(u)) < 2.0); if closes { let pts = std::mem::take(points); cv.gesture = None; diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index 8faae3d02e..f993ecf85b 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,4 +1,4 @@ -use super::geometry::{HANDLES, handle_pos, is_markup, reaches_points, reshapable, reshaped, resizable}; +use super::geometry::{HANDLES, handle_pos, held, is_markup, reaches_points, reshapable, reshaped, resizable}; use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS}; use super::{ Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, @@ -165,10 +165,12 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, stroke)); } CommentTool::Polygon | CommentTool::PolyLine | CommentTool::Cloud => { - // The vertices so far, and a rubber band to the pointer. + // The vertices so far, and a rubber band to where a click would put the next one: the + // first, where the click closes the shape, Shift or not. let mut line = pts.clone(); if let Some(p) = cx.ptr.pos { - line.push(p); + let shift = painter.ctx().input(|i| i.modifiers.shift); + line.push(if closes_at(cx, *tool, points, p) { pts[0] } else { cx.to_screen(held(points.last(), cx.to_user(p), shift)) }); } painter.add(egui::Shape::line(line, stroke)); if let Some(first) = pts.first().filter(|_| *tool != CommentTool::PolyLine && pts.len() >= 3) { @@ -187,6 +189,12 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin } } +/// Whether a click at `p` (on screen) closes the polygon or cloud drawn so far (`points`, user +/// space): on its first point again, once it has three. +pub(super) fn closes_at(cx: &PageCx<'_>, tool: CommentTool, points: &[[f64; 2]], p: Pos2) -> bool { + points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0 +} + /// The shape a finished drawing gesture makes (`None` if it is too small to mean anything). pub(super) fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option { let (first, last) = (*points.first()?, *points.last()?); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 29396830f7..674cd90d26 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -956,8 +956,8 @@ fn comments_are_selected_moved_and_deleted_with_the_select_tool() { } /// A line's ends have handles: dragging one with the Select tool changes the line's length and -/// angle (Shift holds it to 45°), as one undo step. The line tool keeps drawing from an end: a -/// new line starts there rather than dragging the one just drawn. +/// angle (Shift holds it level or plumb with the other end), as one undo step. The line tool keeps +/// drawing from an end: a new line starts there rather than dragging the one just drawn. #[test] fn line_ends_are_dragged_to_change_length_and_angle() { let mut h = harness(|app| app.set_option("quick", "line").unwrap()); @@ -981,7 +981,7 @@ fn line_ends_are_dragged_to_change_length_and_angle() { assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "the dragged end follows: {p:?}"); let id = h.state().views[0].id; assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Reshape comment")); - // Shift: held to the nearest 45° from the other end (39° here). + // Shift: held level or plumb with the other end, here level (110 pt across, 90 up). let a = at(&h, 200.0, 160.0); let b = at(&h, 150.0, 190.0); h.hover_at(a); @@ -998,8 +998,7 @@ fn line_ends_are_dragged_to_change_length_and_angle() { h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); h.run_steps(1); let p = ends(&h); - let (dx, dy) = (p[1][0] - p[0][0], p[1][1] - p[0][1]); - assert!(dx > 50.0 && (dx - dy).abs() < 0.5, "45°: {p:?}"); + assert!((p[1][1] - p[0][1]).abs() < 0.01 && (p[1][0] - 150.0).abs() < 1.0, "level: {p:?}"); // Undo puts the line back as it was before the drag. h.state_mut().undo(); h.run_steps(2); @@ -1083,6 +1082,80 @@ fn alt_arrows_navigate_instead_of_nudging_the_selected_comment() { assert!(comments(&h)[0].rect[0] > before[0], "→ nudged it"); } +/// Over the first corner, where a click closes the polygon, the rubber band runs to that corner +/// with Shift held too: the closing side the click will make, not a level or plumb one. +#[test] +fn the_rubber_band_shows_the_closing_side_with_shift_held() { + let _gpu = crate::gpu(); + let mut h = harness(|app| app.set_option("quick", "polygon").unwrap()); + for p in [(40.0, 40.0), (140.0, 40.0), (140.0, 140.0)] { + click_pt(&mut h, p); + } + h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + h.hover_at(at(&h, 40.0, 40.0) + egui::vec2(2.0, 2.0)); + h.run_steps(3); + let pixels = h.render().unwrap(); + // Halfway along the diagonal closing side, (90, 90). + let m = at(&h, 90.0, 90.0); + let drawn = (-2..=2).any(|d: i32| { + let v = pixels.get_pixel((m.x as i32 + d) as u32, (m.y as i32 + d) as u32).0; + v[0] > 150 && v[1] < 100 && v[2] < 100 + }); + assert!(drawn, "the closing side is drawn"); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(1); +} + +/// Shift holds a new line or arrow level or plumb with where it starts, and each new side of +/// connected lines or a polygon with the corner before (a CAD drawing's ortho mode). Without +/// Shift they go where the pointer is. +#[test] +fn shift_holds_new_lines_and_sides_level_or_plumb() { + let mut h = harness(|app| app.set_option("quick", "line").unwrap()); + let shift = |h: &mut Harness<'static, PdfCraftApp>, on: bool| { + h.event(egui::Event::ModifiersChanged(if on { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE })); + h.run_steps(1); + }; + let points = |h: &Harness<'static, PdfCraftApp>, subtype: &str| { + // In the order drawn: the list isn't sorted by index. + let mut drawn: Vec<_> = comments(h).into_iter().filter(|a| a.subtype == subtype).collect(); + drawn.sort_by_key(|a| a.index); + drawn.into_iter().map(|a| a.points).collect::>() + }; + let close = |p: [f32; 2], q: [f32; 2]| (p[0] - q[0]).abs() < 1.0 && (p[1] - q[1]).abs() < 1.0; + shift(&mut h, true); + drag_pt(&mut h, (40.0, 100.0), (140.0, 120.0)); + drag_pt(&mut h, (200.0, 40.0), (215.0, 150.0)); + shift(&mut h, false); + drag_pt(&mut h, (40.0, 160.0), (140.0, 190.0)); + let lines = points(&h, "Line"); + assert_eq!(lines.len(), 3, "{lines:?}"); + assert!(close(lines[0][0], [40.0, 100.0]) && close(lines[0][1], [140.0, 100.0]), "mostly across: level: {lines:?}"); + assert!(close(lines[1][0], [200.0, 40.0]) && close(lines[1][1], [200.0, 150.0]), "mostly up: plumb: {lines:?}"); + assert!(close(lines[2][1], [140.0, 190.0]), "no Shift, no hold: {lines:?}"); + + h.state_mut().set_option("quick", "arrow").unwrap(); + shift(&mut h, true); + drag_pt(&mut h, (260.0, 180.0), (150.0, 170.0)); + shift(&mut h, false); + let arrow = points(&h, "Line").pop().expect("an arrow"); + assert!(close(arrow[1], [150.0, 180.0]), "an arrow too, leftwards: {arrow:?}"); + + // Connected lines: each click with Shift is held to the corner before. + h.state_mut().set_option("quick", "polyline").unwrap(); + click_pt(&mut h, (20.0, 20.0)); + shift(&mut h, true); + click_pt(&mut h, (90.0, 30.0)); + click_pt(&mut h, (95.0, 80.0)); + shift(&mut h, false); + click_pt(&mut h, (130.0, 60.0)); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let poly = points(&h, "PolyLine").pop().expect("connected lines"); + assert_eq!(poly.len(), 4, "{poly:?}"); + assert!(close(poly[1], [90.0, 20.0]) && close(poly[2], [90.0, 80.0]) && close(poly[3], [130.0, 60.0]), "{poly:?}"); +} + #[test] fn a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool() { // #2338: the new shape showed its handles, but only the Select tool answered them. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f950e36a94..dbb38ce730 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -49,6 +49,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Select and move several objects at once | yes | on one page: Shift/Ctrl/⌘-click and marquee, one undo step (#844); not across pages, no group resize | partial | | Moved comments drawn where they go while dragged | yes | yes: the comment's own pixels follow the pointer, the page shows it where it was until release; stamps and Fill & Sign marks also hide the original | shipped | | Object handles: corners keep proportion, edges free | yes | yes for added images and signatures; Shift on a corner keeps the proportions of comments, stamps and form fields (#2199); no Alt resize from the centre | shipped | +| Shift while drawing a line | holds it to 45° steps | holds it level or plumb (a CAD drawing's ortho): new lines and arrows, each side of connected lines, polygons and clouds, and a dragged end or corner | shipped | | Arrange, align and distribute page objects | yes | fields only | partial | | Rulers, grids, snap to grid, guides | yes | no | planned | | Cursor coordinates, line weights | yes | no | planned | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c62ad80a2d..d4a8c37392 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4095,7 +4095,19 @@ status = "shipped" commands = [] tools = ["comment_edit"] evidence = ["crates/annot/src/tests.rs::lines_and_polygons_are_reshaped_by_their_points", "crates/ui-egui/tests/it/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/it/automation.rs::comments_are_reshaped_through_the_tool"] -notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it to 45° from the point before), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner." +notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it level or plumb with the point before, comment.draw-ortho), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner." + +[[feature]] +id = "comment.draw-ortho" +title = "Shift holds lines level or plumb while drawing (ortho)" +area = "E" +tier = "P1" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_add"] +evidence = ["crates/measure/src/tests.rs::ortho_holds_a_vertex_level_or_plumb_with_the_last", "crates/ui-egui/tests/it/comments.rs::shift_holds_new_lines_and_sides_level_or_plumb", "crates/ui-egui/tests/it/comments.rs::the_rubber_band_shows_the_closing_side_with_shift_held", "crates/ui-egui/tests/it/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/it/automation.rs::lines_and_corners_are_held_level_or_plumb_through_the_tool"] +notes = "With Shift held, a new line or arrow runs level or plumb from where it starts, whichever is nearer to the pointer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before, the rubber band included. Over the first corner, where a click closes a polygon or cloud, the closing side runs straight to that corner, Shift or not. A dragged end or corner of a drawn one is held the same way (comment.reshape). Acrobat holds lines to 45° steps instead; on drawings and plans the level and plumb lines are the ones wanted. Headless: comment_add `ortho`. Measuring tools keep their 45° steps." [[feature]] id = "comment.move-preview" From 983e2f2ad6bfa4b7377163050f9b3fe16af980d3 Mon Sep 17 00:00:00 2001 From: ohagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:25:56 +0100 Subject: [PATCH 08/43] =?UTF-8?q?M3:=20=E2=86=92=20/=20=E2=86=90=20scroll?= =?UTF-8?q?=20a=20page=20wider=20than=20the=20window=20before=20turning=20?= =?UTF-8?q?it=20(#4540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * → / ← scroll a page wider than the window sideways before turning it On a plan zoomed in, the arrow keys turned the page (#185) and never showed its other side: the view moved sideways only by the scroll bar, the wheel or the Hand. Now → / ← scroll a line that way while there is more of the page, and turn the page at its edge, as ↓ / ↑ do in single-page view. ⌘→ / ⌘← turn it at once; a page that fits across turns as before. Co-Authored-By: Claude Opus 5.5 * Review: → / ← read the current page's or spread's edges, not the widest page's; ui-parity's change record In continuous view the content is as wide as the widest page, so on a narrower current page that shows whole → scrolled toward the wide page's edge instead of turning. The edges are now the current page's (or spread's) as drawn, or the content's. Co-Authored-By: Claude Opus 5.5 * Review: a spread's second page off screen still counts for → / ← The row's edges came from the pages' screen transforms, and a page gets one only while it meets the window. With a zoomed two-page spread scrolled so its second page was still wholly to the right of the window, the row ended with the first page, so the next → turned the spread instead of scrolling to the second page. The edges are now taken from the layout rectangles of every page in the row, placed as the drawn pages are. A TwoUp case in the viewer tests zooms a spread until its page gap sits at the window's right edge and checks that → scrolls on rather than turning. Co-Authored-By: Claude Opus 5.5 * Review: → / ← read the row after continuous view has moved the current page In continuous view the current page is updated while the pages are laid out, after the row's extent was taken, so the edges could describe the previous row and the next → or ← turn the page instead of scrolling the page that had just become current. The row is now read after that update. Co-Authored-By: Claude Opus 5.5 * Review: a page turned by → / ← at its edge starts at the edge the reader comes from Turning at a wide page's right edge with →, the next page kept the horizontal scroll (the same column, as other jumps keep it), so an equally wide page opened at its right edge and the next → turned again without showing its left side; ← had the same in reverse. When plain → or ← turns the page, a page or spread wider than the window now starts at its left (after →) or right (after ←). ⌘→ / ⌘←, Page Down / Page Up and every other jump keep the column. The sideways-arrow test now goes across two wide pages both ways: → scrolls the next page from its left edge, ← turns back to the previous page shown from its right edge. Co-Authored-By: Claude Opus 5.5 * Rebase: the sideways arrow keys through main's PageEnv, PageOut and GlideCx, and shown_row with its layout Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 69 +++++++++++-- crates/ui-egui/src/canvas/navigation.rs | 32 +++++- crates/ui-egui/tests/it/viewer.rs | 132 ++++++++++++++++++++++++ docs/ui-parity.md | 5 +- parity/acrobat-features.toml | 4 +- 6 files changed, 229 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b3b0691e16..17ef6e4d75 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -305,6 +305,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. - **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged. - **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged. +- **2026-10-24 (M3, arrow keys on a wide page):** → / ← scroll a page wider than the window (a plan zoomed in) sideways a line at a time and turn the page at its edge, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← turn it at once, and a page that fits across turns as before (#185). A synthetic viewer regression covers both. Estimates unchanged. - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index be2dd97d53..96210fb2fd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -492,6 +492,10 @@ pub struct DocView { pub forward: Vec, /// Pending navigation: page and fraction down the page to align with the viewport top. pub goto: Option<(usize, f32)>, + /// With `goto`: → / ← turned the page at an edge, so a page or spread wider than the window + /// starts at the edge the reader comes from (`false`: its left, after →; `true`: its right, + /// after ←) instead of keeping the column. + pub(crate) goto_side: Option, /// Pending position within that page from a destination ([`Self::go_to_dest`]): the point /// (fractions of the displayed page; `None` leaves that axis to `goto`) to put `rel` points /// from the viewport's top-left corner. @@ -499,6 +503,12 @@ pub struct DocView { /// Pending keyboard scrolling, in points down (negative: up): ↓ / ↑, and Page Down / /// Page Up where the pages scroll. pub key_scroll: f32, + /// Pending keyboard scrolling sideways, in points right (negative: left): → / ← on a page + /// wider than the window. + pub key_side: f32, + /// As last drawn: whether the view was at its left and at its right edge (both when the + /// pages fit across), so → / ← turn the page there and scroll sideways elsewhere. + side_edges: Option<(bool, bool)>, /// Single-page view, as last drawn: the page shown and whether the view was at its top and /// at its bottom, so ↑ / ↓ turn the page there instead of doing nothing (#273). single_edges: Option<(usize, bool, bool)>, @@ -928,8 +938,11 @@ impl DocView { back: Vec::new(), forward: Vec::new(), goto: None, + goto_side: None, goto_point: None, key_scroll: 0.0, + key_side: 0.0, + side_edges: None, single_edges: None, flash: None, compare_marks: Vec::new(), @@ -1162,6 +1175,7 @@ struct PageEnv<'a> { info: &'a DocInfo, initials: &'a Option, key_scroll: f32, + key_side: f32, middle_gesture: bool, modal: bool, navigation_enabled: bool, @@ -1211,6 +1225,9 @@ struct PageOut { page_action: Option, primary_down: bool, refused: Option, + /// The current page or spread where it is drawn, every page of it: one off screen (the second + /// page of a zoomed spread) has no screen transform, but still decides where the row ends. + row_on_screen: Option, stamp_placed: bool, } @@ -1597,6 +1614,7 @@ fn draw_pages( hand, info, key_scroll, + key_side, middle_gesture, modal, navigation_enabled, @@ -1626,7 +1644,7 @@ fn draw_pages( // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution // until after the text widgets when a selection owns that drag. let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + coarse_scroll }; - let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; + let delta = auto_delta + edge_delta - vec2(key_side, key_scroll) + wheel_delta + glide; if delta != Vec2::ZERO && delta.is_finite() { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } @@ -1779,6 +1797,7 @@ fn draw_pages( page_action: _, primary_down: _, refused, + row_on_screen: _, stamp_placed, } = page_out; let mut page_action: Option = None; @@ -1831,6 +1850,9 @@ fn draw_pages( if view.continuous { view.current = current; } + // The row as it is now: in continuous view the current page may have changed above. + let row_on_screen = + shown_row(view, env.rects).iter().filter_map(|&i| env.rects.get(i)).map(|r| r.translate(origin.to_vec2())).reduce(Rect::union); resp.context_menu(|ui| { let is_crop_over_image = tool == QuickTool::EditText && view.image_selection.clone().is_some_and(|sel| { @@ -2006,6 +2028,7 @@ fn draw_pages( page_action, primary_down: ptr.primary_down, refused, + row_on_screen, stamp_placed, }; (wanted, visible_now) @@ -2468,6 +2491,7 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size())); } else if let Some((page, frac)) = view.goto.take() { + let side = view.goto_side.take(); let page = page.min(rects.len() - 1); let r = rects[page]; // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap() @@ -2483,7 +2507,27 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry // quick-action bar as the layout does. let (x, w) = (view.last_scroll.x, avail.width()); let (left, right) = (r.left() - SIDE, r.right() + SIDE); - let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; + // Turned by → / ← at an edge (`goto_side`): a page or spread wider than the window starts at + // the edge the reader comes from. + let row = shown_row(view, rects).iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union).unwrap_or(r); + let (row_left, row_right) = (row.left() - SIDE, row.right() + SIDE); + let target = match side { + Some(from_right) if row_right - row_left > w => { + if from_right { + row_right - w + } else { + row_left + } + } + _ if right - left <= w => { + if left >= x && right <= x + w { + x + } else { + r.center().x - w / 2.0 + } + } + _ => x.clamp(left, right - w), + }; if (target - x).abs() > 0.5 { scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); } @@ -2733,6 +2777,7 @@ struct GlideCx { now: f64, may_glide: bool, key_scroll: f32, + key_side: f32, auto_delta: Vec2, zoom_delta: f32, edge_delta: Vec2, @@ -2746,11 +2791,11 @@ struct GlideCx { /// automatic flash uses `goto`, not these deltas, so its programmatic scroll is never mistaken for /// a reader action. Returns this frame's glide. fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> Vec2 { - let GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx; + let GlideCx { now, may_glide, key_scroll, key_side, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx; let over_page = ui.rect_contains_pointer(avail); ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); let interrupted = glide_interrupted(ui); - if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { + if !may_glide || interrupted || key_scroll != 0.0 || key_side != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { view.fling.stop(); } let glide = view.fling.step(now); @@ -2832,7 +2877,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; // Keyboard scrolling applies to this frame's page view only, never later. let key_scroll = std::mem::take(&mut app.views[index].key_scroll); - if key_scroll != 0.0 { + let key_side = std::mem::take(&mut app.views[index].key_side); + if key_scroll != 0.0 || key_side != 0.0 { app.views[index].end_find_following(); } // The Search panel closed: its search moves to the find bar. @@ -2932,7 +2978,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { read_wheel(ui, view, avail, single_paging, edge_turned, wheel_enabled, animates); let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || coarse_scroll != Vec2::ZERO || has_page; - let motion = GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled }; + let motion = GlideCx { now, may_glide, key_scroll, key_side, auto_delta, zoom_delta, edge_delta, wheel_scrolled }; let glide = step_glide(ui, view, avail, &motion); // The wheel may have turned the page above: draw the row it turned to. Measured from the old // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. @@ -3039,6 +3085,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { info, initials: &initials, key_scroll, + key_side, middle_gesture, modal, navigation_enabled, @@ -3089,6 +3136,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { page_action, primary_down, mut refused, + row_on_screen, stamp_placed, } = page_out; view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| { @@ -3098,6 +3146,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0); (page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0) }); + // Within a point, as above: at an edge of the scrolled content, or of the current page or + // spread shown whole that way (in continuous view the content is as wide as the widest page, + // and a narrower current page shows whole inside it). + let max_x = (out.content_size.x - out.inner_rect.width()).max(0.0); + let inner = out.inner_rect; + view.side_edges = Some(( + out.state.clamped_offset().x <= 1.0 || row_on_screen.is_some_and(|r| r.left() >= inner.left() - 1.0), + out.state.clamped_offset().x >= max_x - 1.0 || row_on_screen.is_some_and(|r| r.right() <= inner.right() + 1.0), + )); view.viewport_screen = out.inner_rect; view.auto_scroll.paint(ui, avail); diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index bc98a243fa..1a2a0343a6 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -424,14 +424,39 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s } } // As in Acrobat: → / ← go to the next / previous page in every layout (#185), and so do - // ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view. + // ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view. On a page wider + // than the window (a plan zoomed in) → / ← scroll sideways a line first, and turn the page + // once there is nothing left to scroll that way, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← + // turn it at once. let command = ctx.input(|i| i.modifiers.command); let scrolls = view.continuous; - if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) { + let line = KEY_SCROLL_LINE * speed.factor(); + // Only an up-to-date view counts: no jump pending. + let sideways = |view: &DocView, right: bool| { + !command && view.goto.is_none() && view.side_edges.is_some_and(|(left_edge, right_edge)| !if right { right_edge } else { left_edge }) + }; + // Turned at an edge, the next page starts at its left and the previous one at its right, as + // reading on across: on two wide pages → doesn't turn twice without showing the next one's left. + let at_edge = |view: &mut DocView, right: bool| { + if !command && view.goto.is_some() { + view.goto_side = Some(!right); + } + }; + if key(Key::ArrowRight) && sideways(view, true) { + view.key_side += line; + } else if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) { view.step_page(true); + if key(Key::ArrowRight) { + at_edge(view, true); + } } - if key(Key::ArrowLeft) || (key(Key::PageUp) && (command || !scrolls)) { + if key(Key::ArrowLeft) && sideways(view, false) { + view.key_side -= line; + } else if key(Key::ArrowLeft) || (key(Key::PageUp) && (command || !scrolls)) { view.step_page(false); + if key(Key::ArrowLeft) { + at_edge(view, false); + } } // ↓ / ↑ scroll a line, and Page Down / Page Up a screen where the pages scroll. In // single-page view, ↓ / ↑ turn the page once there is nothing left to scroll that way, as @@ -439,7 +464,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s // The Scroll speed preference sets how far ↓ / ↑ go as well as the wheel (#985); Page Down / // Page Up keep a screen. if !command { - let line = KEY_SCROLL_LINE * speed.factor(); let screen = (view.viewport_h - KEY_SCROLL_LINE).max(KEY_SCROLL_LINE); let steps = [(Key::ArrowDown, line), (Key::ArrowUp, -line), (Key::PageDown, screen), (Key::PageUp, -screen)]; for (k, by) in steps { diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 6f7d3d3cc8..d383582915 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -1484,6 +1484,138 @@ fn up_and_down_turn_pages_in_single_page_view() { assert_eq!(press(&mut h, Key::ArrowDown, 1), 0, "scrolled up from the bottom of page 1"); } +#[test] +fn left_and_right_scroll_a_page_wider_than_the_window_before_turning_it() { + // A plan zoomed in: → / ← turned the page (#185) and so never showed its other side; the + // view moved sideways only by the scroll bar, the wheel or the Hand. + use egui::{Key, Modifiers}; + let mut h = harness(); + h.state_mut().active = Some(0); + h.state_mut().views[0].set_zoom(12.0); + h.run_steps(6); + let press = |h: &mut Harness<'static, PdfCraftApp>, modifiers, key| { + h.key_press_modifiers(modifiers, key); + h.run_steps(4); + let v = &h.state().views[0]; + (v.current, v.page_screen_rect(v.current).map(|r| r.left())) + }; + let start = h.state().views[0].page_screen_rect(0).map(|r| r.left()).expect("on screen"); + let (page, left) = press(&mut h, Modifiers::NONE, Key::ArrowRight); + assert_eq!(page, 0, "→ scrolls the wide page rather than turning it"); + assert!(left.is_some_and(|l| l < start - 10.0), "the page moved left: {left:?} from {start}"); + // At its right edge the next → turns the page. + let mut n = 1; + while press(&mut h, Modifiers::NONE, Key::ArrowRight).0 == 0 { + n += 1; + assert!(n < 500, "→ never reached the right edge"); + } + assert!(n > 3, "→ scrolled across page 1 before turning ({n} presses)"); + assert_eq!(h.state().views[0].current, 1); + // Page 2 starts at its left edge, as reading on across: → scrolls it rather than turning + // again at once. + let shown = |h: &Harness<'static, PdfCraftApp>, page: usize| { + let v = &h.state().views[0]; + (v.page_screen_rect(page).expect("on screen"), v.viewport_rect()) + }; + let (r, view) = shown(&h, 1); + assert!(r.left() >= view.left() && r.right() > view.right(), "page 2 from its left edge: {r:?} in {view:?}"); + assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowRight).0, 1, "→ scrolls page 2 from its left edge"); + // ← goes back to that edge and turns back: page 1 comes at its right edge, so the next ← scrolls it. + let mut n = 1; + while press(&mut h, Modifiers::NONE, Key::ArrowLeft).0 == 1 { + n += 1; + assert!(n < 50, "← never turned back from page 2"); + } + assert_eq!(h.state().views[0].current, 0); + let (r, view) = shown(&h, 0); + assert!(r.right() <= view.right() && r.left() < view.left(), "page 1 from its right edge: {r:?} in {view:?}"); + assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowLeft).0, 0, "← scrolls page 1 from its right edge"); + // ⌘→ turns at once. + assert_eq!(press(&mut h, Modifiers::COMMAND, Key::ArrowRight).0, 1, "⌘→ turns the page at once"); + assert_eq!(press(&mut h, Modifiers::COMMAND, Key::ArrowRight).0, 2); + // A page that fits across: → turns pages as before. + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width; + h.run_steps(6); + assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowRight).0, 3); +} + +/// Continuous view with pages of different widths: the content is as wide as the widest page, but +/// on a narrow page that shows whole → turns the page; on the wide one it scrolls first. +#[test] +fn left_and_right_follow_the_current_pages_width_in_continuous_view() { + use egui::Key; + 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.open_bytes("mixed.pdf", None, sized_fixture(&[(200, 1200), (3000, 1200), (200, 1200)])).unwrap(); + app + }); + crate::support::settle(&mut h); + h.state_mut().active = Some(0); + assert!(h.state().views[0].continuous); + // Each page taller than the window, so one is the current page. + h.state_mut().views[0].set_zoom(1.0); + h.run_steps(6); + h.state_mut().views[0].go_to_page(0); + h.run_steps(6); + let press = |h: &mut Harness<'static, PdfCraftApp>| { + h.key_press(Key::ArrowRight); + h.run_steps(4); + let v = &h.state().views[0]; + (v.current, v.page_screen_rect(1).map(|r| r.left())) + }; + assert_eq!(h.state().views[0].current, 0); + let (page, start) = press(&mut h); + assert_eq!(page, 1, "→ on the narrow page shown whole turns it"); + let (page, left) = press(&mut h); + assert_eq!(page, 1, "→ on the wide page scrolls it"); + assert!(left.zip(start).is_some_and(|(l, s)| l < s - 10.0), "the wide page moved left: {left:?} from {start:?}"); +} + +/// A zoomed two-page spread scrolled so its first page ends inside the window and the second is +/// still wholly to the right of it: → scrolls on to the second page rather than turning the +/// spread. (Read from the pages on screen alone, the spread seemed to end with its first page.) +#[test] +fn right_scrolls_to_a_spreads_second_page_off_screen_before_turning() { + use egui::Key; + // The page gap (points on screen). + const GAP: f32 = 14.0; + let build = |zoom: f32| { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("spread.pdf", None, sized_fixture(&[(600, 800); 4])).unwrap(); + app.set_option("layout", "two-page").unwrap(); + // Before the first layout: zoomed about the content's top left, scrolled to it. + app.views[0].set_zoom(zoom); + app + }); + crate::support::settle(&mut h); + h.state_mut().active = Some(0); + h.run_steps(4); + h + }; + let shown = + |h: &Harness<'static, PdfCraftApp>| (h.state().views[0].viewport_rect(), h.state().views[0].page_screen_rect(0).expect("page 1 on screen")); + // Zoom until page 1's right edge sits half a gap inside the window's. + let mut zoom = 2.0; + let mut h = build(zoom); + for _ in 0..3 { + let (inner, first) = shown(&h); + zoom *= (inner.right() - GAP / 2.0 - first.left()) / first.width(); + h = build(zoom); + } + let (inner, first) = shown(&h); + assert!(first.right() <= inner.right() && first.right() + GAP > inner.right(), "the spread's gap at the window's edge: {first:?} in {inner:?}"); + assert!(h.state().views[0].page_screen_rect(1).is_none(), "its second page off screen"); + assert!(h.state().views[0].layout == pdfcraft_ui_egui::canvas::PageLayout::TwoUp && !h.state().views[0].continuous); + h.key_press(Key::ArrowRight); + h.run_steps(4); + let v = &h.state().views[0]; + assert!(v.current < 2, "→ doesn't turn the spread: page {}", v.current + 1); + assert!(v.page_screen_rect(1).is_some(), "it scrolls the second page into view"); +} + #[test] fn v_h_and_space_pick_the_quick_tools() { // The toolbar's tooltips promise "Select (V)" and "Hand (H)", but the keys did nothing. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index dbb38ce730..69072e259c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (Pages inspector thumbnail size slider and buttons #3808; full screen presents one page at a time #1459; the selected field nudges with the arrow keys in Prepare a form #2608) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the 2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is @@ -44,7 +44,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Zoom: wheel, pinch, keys, marquee, fit page/width/height/visible | yes | yes | shipped | | Fixed zoom percentages match Acrobat's physical size | yes | differ (#739, documented in #736) | gap | | Dynamic zoom, loupe, pan-and-zoom window | yes | no | planned | -| Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll | partial | +| Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll; → / ← scroll a page wider than the window before turning it | partial | | Text selection, column select | yes, Alt-drag columns | reading-order selection; Alt/Option-drag or Edit ▸ Column select (#740) | shipped | | Select and move several objects at once | yes | on one page: Shift/Ctrl/⌘-click and marquee, one undo step (#844); not across pages, no group resize | partial | | Moved comments drawn where they go while dragged | yes | yes: the comment's own pixels follow the pointer, the page shows it where it was until release; stamps and Fill & Sign marks also hide the original | shipped | @@ -83,6 +83,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | → / ← scroll a page wider than the window sideways before turning it, by the current page's or spread's edges in continuous view; ⌘→ / ⌘← turn at once | | 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. | | 2026-10-15 | minor | A selected field nudges with the arrow keys in Prepare a form: a point, Shift ten points, one undo step per press (#2608) | | 2026-10-14 | minor | Full screen shows one fitted page (or spread) at a time on black with click and key paging, as Acrobat's Full Screen Mode (#379, #1459, #1353) | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d4a8c37392..1a3edc4c36 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1154,8 +1154,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document"] -notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout, ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel" +evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document", "crates/ui-egui/tests/it/viewer.rs::left_and_right_scroll_a_page_wider_than_the_window_before_turning_it"] +notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout (on a page wider than the window, a plan zoomed in, they scroll sideways a line first and turn the page at its edge; ⌘→ / ⌘← turn at once), ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel" [[feature]] id = "view.goto-page-label" From c3c36f77cf1f1beb8785ad5caccf6cb654f598c5 Mon Sep 17 00:00:00 2001 From: ohagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:39:36 +0100 Subject: [PATCH 09/43] M5: a comment's text, border or line, and fill each at an opacity of its own (#4542) * A comment's text, border or line, and fill each at an opacity of its own Comment Properties showed one opacity for a whole comment, so a text box's see-through background took its text and border with it, and a rectangle marking an area on a plan couldn't have an opaque line over a faint fill. Now a text box, typewriter text or callout has a Text, Border and Fill opacity, a rectangle, oval, polygon or cloud a Line and Fill opacity (a part not drawn is greyed); everything else keeps one Opacity. The line's stays /CA, the fill's is /ca (PDF 2.0's opacity for what is filled) and the text's /PCTextOpacity, each only where it differs from the one it would follow, and the appearance draws them with /GS0 (strokes at the line's, fills at the fill's) and /GS1 for the text, so every viewer shows them. The quick bar's Opacity and StyleAnnotation's set the whole comment's. Engine: Style::{fill_opacity, text_opacity}, Edit::PartOpacity, annot::{set_part_opacity, appearance::part_opacity}, Props::{fill_opacity, text_opacity}. Make Properties Default and the settings carry the parts. Headless: comment_add / comment_edit fill_opacity, text_opacity; comment_list reports opacity, fill_opacity, text_opacity. Four labels in every catalog (AI-assisted translations from English, noted in ATTRIBUTION.toml). Co-Authored-By: Claude Opus 5.5 * Review: the text's opacity slider shows what the text is drawn at; comment_list skips replies' properties; ui-parity's change record A text box without fill and border whose text kept an opacity of its own showed the border's in its Text opacity slider; it now shows the text's, and a change sets the whole comment's. The opacity sliders are named by their labels. comment_list reads a comment's properties only for comments, not for every reply. Co-Authored-By: Claude Opus 5.5 * Rebase: comment_list tells replies by is_reply, as main now threads them main threads replies by their /IRT reference (#4061); the opacity fields skip a reply the same way. Co-Authored-By: Claude Opus 5.5 * Review: a text-only text box made default gives the tool its text's opacity With neither a border nor a fill, a text box's text is the whole comment: Comment Properties shows its drawn opacity (the text's own, when set), and a change there sets the whole comment's. Make Properties Default, and the context menu's Make Current Properties Default, stored /CA instead, so a text box whose text was at 50 % gave the tool 100 %. The default is now the text's drawn opacity in that case. Co-Authored-By: Claude Opus 5.5 * Rebase: the part opacities in main's annot layout (Style's fields, the text box drawing's parts, set_part_opacity exported) Co-Authored-By: Claude Opus 5.5 * Rebase: main's new comment test builds a Style with the part opacities Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- ROADMAP.md | 1 + crates/annot/src/appearance.rs | 60 +++++++-- crates/annot/src/appearance/symbols.rs | 4 +- crates/annot/src/create.rs | 17 ++- crates/annot/src/info.rs | 9 +- crates/annot/src/lib.rs | 2 +- crates/annot/src/modify.rs | 67 +++++++++- crates/annot/src/tests.rs | 83 ++++++++++++- crates/annot/src/types.rs | 13 +- crates/automation/src/comments.rs | 36 +++++- crates/automation/src/tools.rs | 10 +- crates/automation/tests/it/automation.rs | 49 ++++++++ crates/engine/src/edit.rs | 9 ++ crates/engine/src/edit/labels.rs | 8 +- crates/engine/src/edit/run.rs | 2 + crates/engine/src/history.rs | 1 + crates/engine/src/tests.rs | 2 +- crates/ui-egui/src/app/prefs.rs | 4 + crates/ui-egui/src/dialogs/comment_props.rs | 79 +++++++++++- crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/tools/comments.rs | 9 ++ crates/ui-egui/src/tools/comments/menu.rs | 2 + crates/ui-egui/tests/it/comments.rs | 131 +++++++++++++++++++- crates/ui-egui/tests/it/ui.rs | 22 ++++ crates/xfdf/src/tests.rs | 2 +- docs/ui-parity.md | 5 +- parity/acrobat-features.toml | 12 ++ 27 files changed, 584 insertions(+), 56 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 17ef6e4d75..cf8cacb73e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -306,6 +306,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged. - **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged. - **2026-10-24 (M3, arrow keys on a wide page):** → / ← scroll a page wider than the window (a plan zoomed in) sideways a line at a time and turn the page at its edge, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← turn it at once, and a page that fits across turns as before (#185). A synthetic viewer regression covers both. Estimates unchanged. +- **2026-10-24 (M5, part opacity):** Comment Properties sets a text box's text, border and fill, and a shape's line and fill, each at an opacity of its own (a see-through fill with an opaque line on a plan); stored as /CA, /ca (PDF 2.0) and /PCTextOpacity and drawn with separate graphics states, so other viewers show it. Make Properties Default and the settings carry it; comment_add / comment_edit `fill_opacity`, `text_opacity`. Four new labels in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged. - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 89c0f70f48..dbecae5c6d 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -262,19 +262,47 @@ pub fn parse_da(d: &Dict) -> (Rgb, f64) { (col.map(|x| x.clamp(0.0, 1.0)), size.min(400.0)) } -fn ext_gstate(opacity: f64, multiply: bool) -> Option { - if opacity >= 1.0 && !multiply { +/// How opaque a comment's parts are drawn, 0–1: its line or border (`/CA`); its fill or +/// background (`/ca`, which PDF 2.0 gives the non-stroking operations; without it, as the line); +/// and a text box's text (`/PCTextOpacity`, PdfCraft's own; without it, as the fill, since text +/// is filled too). A comment with only `/CA` is drawn at that opacity throughout, as before. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PartOpacity { + pub line: f64, + pub fill: f64, + pub text: f64, +} + +/// The parts' opacities of the comment `d` (see [`PartOpacity`]). +pub fn part_opacity(d: &Dict) -> PartOpacity { + let get = |key: &[u8]| d.get(key).and_then(|o| o.as_f64()).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0)); + let line = get(b"CA").unwrap_or(1.0); + let fill = get(b"ca").unwrap_or(line); + let text = get(b"PCTextOpacity").unwrap_or(fill); + PartOpacity { line, fill, text } +} + +/// `/GS0`: the line's opacity for strokes and the fill's for everything filled; `/GS1`, where the +/// text's differs from the fill's: the text's for its glyphs. +fn ext_gstate(parts: PartOpacity, multiply: bool) -> Option { + if parts.line >= 1.0 && parts.fill >= 1.0 && parts.text >= 1.0 && !multiply { return None; } - let mut gs = Dict::new(); - gs.set(b"Type".to_vec(), Object::name("ExtGState")); - if multiply { - gs.set(b"BM".to_vec(), Object::name("Multiply")); - } - gs.set(b"CA".to_vec(), Object::Real(opacity)); - gs.set(b"ca".to_vec(), Object::Real(opacity)); + let gs = |stroke: f64, fill: f64| { + let mut gs = Dict::new(); + gs.set(b"Type".to_vec(), Object::name("ExtGState")); + if multiply { + gs.set(b"BM".to_vec(), Object::name("Multiply")); + } + gs.set(b"CA".to_vec(), Object::Real(stroke)); + gs.set(b"ca".to_vec(), Object::Real(fill)); + Object::Dict(gs) + }; let mut res = Dict::new(); - res.set(b"GS0".to_vec(), Object::Dict(gs)); + res.set(b"GS0".to_vec(), gs(parts.line, parts.fill)); + if parts.text != parts.fill { + res.set(b"GS1".to_vec(), gs(parts.line, parts.text)); + } Some(res) } @@ -391,7 +419,9 @@ struct AppearanceContext<'a> { subtype: &'a [u8], rect: [f64; 4], cloudy: bool, + /// The line's opacity (`parts.line`). opacity: f64, + parts: PartOpacity, stroke: Option, width: f64, } @@ -408,7 +438,7 @@ impl AppearanceDrawing { fn new(context: &AppearanceContext<'_>) -> Self { let mut drawing = Self::default(); if let Some(gs) = ext_gstate( - context.opacity, + context.parts, context.subtype == b"Highlight" || (context.subtype == b"Ink" && context.dict.name(b"IT") == Some(b"FreehandHighlight")), ) { drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs)); @@ -455,7 +485,8 @@ fn appearance_context(d: &Dict) -> Option> { subtype, rect, cloudy, - opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0), + opacity: part_opacity(d).line, + parts: part_opacity(d), stroke: color(d, b"C")?, width: border_width(d), }) @@ -904,6 +935,7 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { let d = context.dict; + let parts = context.parts; let rect = context.rect; let stroke = context.stroke; let mut c = std::mem::take(&mut drawing.content); @@ -977,6 +1009,10 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin let pad = 2.0 + bw; let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); let q = d.int(b"Q").unwrap_or(0); + // The text at its own opacity, where it has one. + if parts.text != parts.fill { + c.push_str("/GS1 gs\n"); + } c.push_str(&format!( "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", n(rect[0]), diff --git a/crates/annot/src/appearance/symbols.rs b/crates/annot/src/appearance/symbols.rs index 3b33662524..3178f78e94 100644 --- a/crates/annot/src/appearance/symbols.rs +++ b/crates/annot/src/appearance/symbols.rs @@ -1,4 +1,4 @@ -use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi}; +use super::{Dict, Object, PartOpacity, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi}; /// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp /// another program wrote without an appearance. Names are matched byte for byte, as PDF names @@ -51,7 +51,7 @@ pub(crate) fn display_stamp(d: &Dict) -> Option { // A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp. let stroke = color(d, b"C").flatten(); let mut res = Dict::new(); - if let Some(gs) = ext_gstate(opacity, false) { + if let Some(gs) = ext_gstate(PartOpacity { line: opacity, fill: opacity, text: opacity }, false) { res.set(b"ExtGState".to_vec(), Object::Dict(gs)); } // A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default. diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 1904745e95..298dc67f41 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,6 +1,6 @@ use super::draw::draw_appearance; use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; -use super::modify::{n, rich_text}; +use super::modify::{n, rich_text, set_part_keys}; use super::types::{ FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, }; @@ -214,8 +214,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R let rect = rect_for(&new.shape, style)?; let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta); d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape))); - let opacity = style.opacity.clamp(0.0, 1.0); - set_non_opaque_style(&mut d, opacity); + if style.fill_opacity.into_iter().chain(style.text_opacity).any(|o| !o.is_finite()) { + return Err(AnnotError::Invalid("invalid opacity".into())); + } + let line = style.opacity.clamp(0.0, 1.0); + let fill = style.fill_opacity.map_or(line, |o| o.clamp(0.0, 1.0)); + let text = style.text_opacity.map_or(fill, |o| o.clamp(0.0, 1.0)); + set_part_keys(&mut d, appearance::PartOpacity { line, fill, text }); let border = |d: &mut Dict| { let mut bs = Dict::new(); bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0))); @@ -443,12 +448,6 @@ fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]], } } -fn set_non_opaque_style(dict: &mut Dict, opacity: f64) { - if opacity < 1.0 { - dict.set(b"CA".to_vec(), Object::Real(opacity)); - } -} - fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec) -> Result<(), AnnotError> { let Some(rect) = rect else { return Ok(()) }; let mut popup = Dict::new(); diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index 1a54dbc077..ce001bcf29 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -217,6 +217,11 @@ pub struct Props { pub fill: Option, /// Text boxes, typewriter text and callouts: the `/DA` font size in points. pub font_size: Option, + /// How opaque the fill is drawn, for a comment with a fill control (`opacity` is then its + /// line's or border's). + pub fill_opacity: Option, + /// How opaque a text box's, typewriter text's or callout's text is drawn. + pub text_opacity: Option, } /// The current properties of the comment at `(page, index)`. @@ -239,7 +244,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { author: text_value(doc, d, b"T").unwrap_or_default(), subject: text_value(doc, d, b"Subj").unwrap_or_default(), color, - opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0), + opacity: appearance::part_opacity(d).line, width, icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)), modified: text_value(doc, d, b"M"), @@ -248,6 +253,8 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { fillable: fillable(&subtype), fill: fill_of(d), font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), + fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill), + text_opacity: (subtype == "FreeText").then(|| appearance::part_opacity(d).text), subtype, endings: line_endings_of(d), }) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 4f24d04b08..6d555dbb9c 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -42,7 +42,7 @@ pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, - set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, + set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, }; pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model}; pub use types::{ diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index a615b8e013..469815af7f 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -558,7 +558,8 @@ fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> { Some([inner[0] - base[0] - new_half, inner[1] - base[1] - new_half, inner[2] + base[2] + new_half, inner[3] + base[3] + new_half]) } -/// Change a comment's colour, opacity, line width and/or line endings, and redraw it. +/// Change a comment's colour, opacity (of the whole comment), line width and/or line endings, and +/// redraw it. pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> { let StyleChange { color, opacity, width, endings } = change; let (_, r) = annot_ref(doc, page, index)?; @@ -593,13 +594,10 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha d.set(b"C".to_vec(), rgb(c)); } } + // The whole comment: its fill and text drop opacities of their own. if let Some(o) = opacity { let o = o.clamp(0.0, 1.0); - if o < 1.0 { - d.set(b"CA".to_vec(), Object::Real(o)); - } else { - d.remove(b"CA"); - } + set_part_keys(d, appearance::PartOpacity { line: o, fill: o, text: o }); } if let Some(w) = width { let old_w = border_width_of(d); @@ -707,6 +705,63 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option redraw_or_restore(doc, r, before) } +/// Draw a rectangle's, oval's, polygon's or text box's fill, or a text box's text, at an +/// opacity of its own (0–1), its line or border keeping the comment's (`/CA`), and redraw it. +/// `None` leaves that part as it is drawn now. The fill goes in `/ca` (PDF 2.0's opacity for +/// what is filled), the text in `/PCTextOpacity`, each only where it differs from the one it +/// would otherwise follow. +pub fn set_part_opacity(doc: &mut Document, page: usize, index: usize, fill: Option, text: Option, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + if fill.is_some() && !fillable(&subtype) { + return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes have a fill of their own".into())); + } + if text.is_some() && subtype != "FreeText" { + return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have text of their own".into())); + } + if fill.into_iter().chain(text).any(|o| !o.is_finite()) { + return Err(AnnotError::Invalid("invalid opacity".into())); + } + // Check before changing anything: a stale appearance would contradict the new opacity. + if appearance::build(&d).is_none() { + return Err(AnnotError::Unsupported(subtype)); + } + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + let mut parts = appearance::part_opacity(d); + if let Some(f) = fill { + parts.fill = f.clamp(0.0, 1.0); + } + if let Some(t) = text { + parts.text = t.clamp(0.0, 1.0); + } + set_part_keys(d, parts); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + +/// Write a comment's parts' opacities (see [`appearance::PartOpacity`]): the line's as `/CA`, the +/// fill's as `/ca` and the text's as `/PCTextOpacity`, each only where it differs from the one +/// it would otherwise follow (the fill the line, the text the fill), so a comment drawn at one +/// opacity keeps just `/CA`. +pub(crate) fn set_part_keys(d: &mut Dict, parts: appearance::PartOpacity) { + let appearance::PartOpacity { line, fill, text } = parts; + let mut put = |key: &[u8], value: f64, unless: f64| { + if value == unless { + d.remove(key); + } else { + d.set(key.to_vec(), Object::Real(value)); + } + }; + put(b"CA", line, 1.0); + put(b"ca", fill, line); + put(b"PCTextOpacity", text, fill); +} + /// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent. fn ensure_ending_room(d: &mut Dict) { let subtype = d.name(b"Subtype").unwrap_or_default(); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 2ff781a3a5..91b36550cf 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -40,7 +40,7 @@ fn new(page: usize, shape: Shape) -> NewAnnotation { NewAnnotation { page, shape, - style: Style { color: [0.0, 0.4, 1.0], opacity: 1.0, width: 2.0, fill: None }, + style: Style { color: [0.0, 0.4, 1.0], opacity: 1.0, width: 2.0, fill: None, ..Style::default() }, contents: "hello".into(), author: "Ada".into(), } @@ -356,6 +356,83 @@ fn restyle_and_resize_redraw_the_appearance() { assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_)))); } +/// A rectangle's fill, and a text box's border, background and text, each drawn at an opacity of +/// its own: the line's in `/CA`, the fill's in `/ca` (PDF 2.0), the text's in `/PCTextOpacity`, and +/// in the appearance `/GS0` (strokes at the line's, fills at the fill's) and `/GS1` for the text. +#[test] +fn parts_are_drawn_at_opacities_of_their_own() { + let num = |d: &Dict, k: &[u8]| d.get(k).and_then(|o| o.as_f64()); + let gstate = |doc: &Document, d: &Dict, name: &[u8]| -> Option<(f64, f64)> { + let n = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N")))?; + let obj = doc.get(n); + let Object::Stream(s) = &*obj else { return None }; + let res = s.dict.get(b"Resources").map(|r| doc.resolve(r))?; + let gs = res.as_dict()?.get(b"ExtGState").map(|g| doc.resolve(g))?; + let gs = gs.as_dict()?.get(name).map(|g| doc.resolve(g))?; + let gs = gs.as_dict()?; + Some((gs.get(b"CA")?.as_f64()?, gs.get(b"ca")?.as_f64()?)) + }; + let mut doc = fixture(); + let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap(); + set_fill(&mut doc, 0, r, Some([1.0, 1.0, 0.0]), &meta("")).unwrap(); + set_part_opacity(&mut doc, 0, r, Some(0.3), None, &meta("")).unwrap(); + let doc2 = reopen(&doc); + let d = &list(&doc2, 0)[r]; + assert_eq!((num(d, b"CA"), num(d, b"ca")), (None, Some(0.3)), "an opaque line, a fill of its own"); + assert_eq!(gstate(&doc2, d, b"GS0"), Some((1.0, 0.3))); + let ap = ap_content(&doc2, d); + assert!(ap.starts_with("/GS0 gs") && ap.contains("\nB\n"), "filled and stroked in one go: {ap}"); + let p = props(&doc2, 0, r).unwrap(); + assert_eq!((p.opacity, p.fill_opacity, p.text_opacity), (1.0, Some(0.3), None)); + // The whole comment's opacity (StyleAnnotation, the quick bar): the fill goes along again. + set_style(&mut doc, 0, r, StyleChange::new(None, Some(0.6), None, None), &meta("")).unwrap(); + let d = &list(&doc, 0)[r]; + assert_eq!((num(d, b"CA"), num(d, b"ca")), (Some(0.6), None)); + assert_eq!(props(&doc, 0, r).unwrap().fill_opacity, Some(0.6)); + + // A text box with its border opaque, its background at 20 % and its text opaque, as made. + let mut tb = new(0, Shape::TextBox { rect: [200.0, 600.0, 400.0, 650.0], font_size: 12.0 }); + tb.style.fill = Some([1.0, 1.0, 0.6]); + tb.style.width = 1.0; + (tb.style.fill_opacity, tb.style.text_opacity) = (Some(0.2), Some(1.0)); + let t = add_annotation(&mut doc, &tb, &meta("t")).unwrap(); + let doc2 = reopen(&doc); + let d = &list(&doc2, 0)[t]; + assert_eq!((num(d, b"CA"), num(d, b"ca"), num(d, b"PCTextOpacity")), (None, Some(0.2), Some(1.0))); + assert_eq!((gstate(&doc2, d, b"GS0"), gstate(&doc2, d, b"GS1")), (Some((1.0, 0.2)), Some((1.0, 1.0)))); + let ap = ap_content(&doc2, d); + let (gs1, text) = (ap.find("/GS1 gs\n").expect("the text's own state, an operator of its own"), ap.find("BT").expect("text")); + assert!(ap.find(" re f").is_some_and(|fill| fill < gs1) && gs1 < text, "background at 20 %, then the text at its own: {ap}"); + // A new fill opacity leaves the text as it is drawn. + set_part_opacity(&mut doc, 0, t, Some(0.5), None, &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + assert_eq!((num(d, b"ca"), num(d, b"PCTextOpacity")), (Some(0.5), Some(1.0))); + let p = props(&doc, 0, t).unwrap(); + assert_eq!((p.opacity, p.fill_opacity, p.text_opacity), (1.0, Some(0.5), Some(1.0))); + // All at one opacity again: only /CA stays. + set_part_opacity(&mut doc, 0, t, Some(1.0), Some(1.0), &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + assert_eq!((num(d, b"CA"), num(d, b"ca"), num(d, b"PCTextOpacity")), (None, None, None)); + + // Only the parts a comment has, and finite values; nothing changes when refused. + let l = add_annotation( + &mut doc, + &new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], start: LineEnding::None, end: LineEnding::None }), + &meta("l"), + ) + .unwrap(); + assert!(matches!(set_part_opacity(&mut doc, 0, l, Some(0.5), None, &meta("")), Err(AnnotError::Invalid(_))), "a line has no fill"); + assert!(matches!(set_part_opacity(&mut doc, 0, r, None, Some(0.5), &meta("")), Err(AnnotError::Invalid(_))), "a rectangle has no text"); + let before = list(&doc, 0)[r].clone(); + assert!(matches!(set_part_opacity(&mut doc, 0, r, Some(f64::NAN), None, &meta("")), Err(AnnotError::Invalid(_)))); + assert_eq!(list(&doc, 0)[r], before); + // Another app's comment with /CA alone is drawn at it throughout, as before. + assert_eq!( + appearance::part_opacity(&Dict::from_iter([(b"CA".to_vec(), Object::Real(0.4))])), + appearance::PartOpacity { line: 0.4, fill: 0.4, text: 0.4 } + ); +} + #[test] fn shapes_can_be_filled_and_unfilled() { let mut doc = fixture(); @@ -873,8 +950,8 @@ fn cloud_ys(path: &str) -> Vec { fn replace_text_groups_a_strikeout_and_a_caret() { let mut doc = fixture(); let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; - let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; - let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None }; + let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() }; + let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None, ..Style::default() }; let i = add_text_replacement( &mut doc, TextReplacement { diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 522995b9f9..2831613091 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -566,6 +566,11 @@ pub struct Style { pub width: f64, /// Interior fill for rectangles and ovals (`/IC`), background for text boxes. pub fill: Option, + /// The fill's own opacity, 0–1 (`/ca`), where it differs from `opacity` (which is then the + /// line's or border's); `None`: as `opacity`. + pub fill_opacity: Option, + /// A text box's text's own opacity, 0–1 (`/PCTextOpacity`); `None`: as the fill's. + pub text_opacity: Option, } /// The style properties changed by one annotation edit. `None` leaves a property unchanged. @@ -597,7 +602,7 @@ pub struct TextReplacement<'a> { impl Default for Style { fn default() -> Self { - Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None } + Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None, fill_opacity: None, text_opacity: None } } } @@ -615,7 +620,7 @@ impl Style { } Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0), Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0), - Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, fill: None }, + Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, ..Self::default() }, Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), @@ -624,9 +629,9 @@ impl Style { Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0), Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0), // Red outline while marked; a black box once applied. - Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) }, + Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], fill: Some([0.0, 0.0, 0.0]), ..Self::default() }, }; - Self { color, opacity: 1.0, width, fill: None } + Self { color, width, ..Self::default() } } } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index a1e5e05263..2ca9fbd540 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -190,6 +190,19 @@ impl Automation { "rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))), "color": c.color.map(hex), }); + // How opaque it is drawn: as a whole, or its fill and text each at their own. + // Not for replies, which are drawn with the comment they answer. + if !c.is_reply() + && let Some(p) = doc.comment_props(c.page, c.index) + { + v["opacity"] = json!(p.opacity); + if let Some(f) = p.fill_opacity { + v["fill_opacity"] = json!(f); + } + if let Some(t) = p.text_opacity { + v["text_opacity"] = json!(t); + } + } // A line's ends, or the corners of connected lines, a polygon or a cloud, as // comment_edit's `points` takes them. // Unchanged on a page the viewer could not read, as `rect` is. @@ -437,6 +450,19 @@ impl Automation { if let Some(o) = a.opt_num("opacity")? { style.opacity = o.clamp(0.0, 1.0); } + // The fill's and a text box's text's own opacities (the line keeps `opacity`). + let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?); + let texts = matches!(shape, Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. }); + if fill_opacity.is_some() && !(texts || matches!(shape, Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Polygon { .. })) { + return Err(ToolError::InvalidArgs( + "fill_opacity applies to a rectangle, oval, polygon, cloud, text box, typewriter text or callout".into(), + )); + } + if text_opacity.is_some() && !texts { + return Err(ToolError::InvalidArgs("text_opacity applies to a text box, typewriter text or callout".into())); + } + style.fill_opacity = fill_opacity.map(|o| o.clamp(0.0, 1.0)); + style.text_opacity = text_opacity.map(|o| o.clamp(0.0, 1.0)); if let Some(w) = a.opt_num("width")? { style.width = w.max(0.0); } @@ -668,6 +694,14 @@ impl Automation { let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) }; edits.push(Edit::FillAnnotation { page, index, fill }); } + // After `opacity`, which sets the whole comment's. + let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?); + if fill_opacity.into_iter().chain(text_opacity).any(|o| !(0.0..=1.0).contains(&o)) { + return Err(ToolError::InvalidArgs("fill_opacity and text_opacity are 0–1".into())); + } + if fill_opacity.is_some() || text_opacity.is_some() { + edits.push(Edit::PartOpacity { page, index, fill: fill_opacity, text: text_opacity }); + } if let Some(size) = a.opt_num("font_size")? { if !(1.0..=400.0).contains(&size) { return Err(ToolError::InvalidArgs("font_size must be 1–400 pt".into())); @@ -701,7 +735,7 @@ impl Automation { } if edits.is_empty() { return Err(ToolError::InvalidArgs( - "nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(), + "nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, font_size, rect, move or points".into(), )); } let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } }; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0728679c8e..7b55a80094 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -858,7 +858,9 @@ pub fn tools() -> Vec { "icon": { "type": "string", "description": "note: Comment, Note, Help, Insert, Key, NewParagraph, Paragraph (the standard names), Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow; attachment: PushPin, Paperclip, Graph, Tag." }, "color": color(), "fill": color(), - "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, + "opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The whole comment's opacity (its line or border's, where its fill or text has its own)." }, + "fill_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The fill's own opacity: rectangle, oval, polygon, cloud, text box, typewriter text or callout." }, + "text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity: text box, typewriter text or callout." }, "width": { "type": "number", "minimum": 0, "description": "Line width in points." }, "endings": { "type": "array", "items": { "type": "string", "enum": ["None", "Square", "Circle", "Diamond", "OpenArrow", "ClosedArrow", "Butt", "ROpenArrow", "RClosedArrow", "Slash"] }, "minItems": 1, "maxItems": 2, "description": "Line endings. Two names (start, end) for a line, arrow or polyline; one name for a callout." }, "ortho": { "type": "boolean", "description": "line/arrow/polygon/cloud/polyline: hold each end or corner level or plumb with the one before, whichever is nearer (Shift while drawing)." }, @@ -992,11 +994,13 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }), &["doc"], )), - t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( + t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( comment_ref(json!({ "contents": { "type": "string" }, "color": color(), - "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, + "opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The whole comment's opacity: its fill and text drop opacities of their own." }, + "fill_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The fill's own opacity (rectangle, oval, polygon, cloud, text box, typewriter text, callout); the line or border keeps the comment's." }, + "text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity (text box, typewriter text, callout)." }, "width": { "type": "number", "minimum": 0 }, "fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." }, "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 5bde9d27f9..42a88a3633 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2135,6 +2135,55 @@ fn numbering_pages_through_tools() { assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 1, "to": 1, "style": "klingon" })), Err(ToolError::InvalidArgs(_)))); } +/// A rectangle's fill and a text box's text each take an opacity of their own through the tools +/// (the line or border keeps the comment's `opacity`); `opacity` sets the whole comment's again. +#[test] +fn comment_parts_take_opacities_of_their_own_through_the_tools() { + let dir = workdir("part-opacity"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "fill": "yellow", "fill_opacity": 0.25 }), + ); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "fill": "#FFFF99", "width": 1, "fill_opacity": 0.2, "text_opacity": 1 }), + ); + let find = |a: &mut Automation, kind: &str| { + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 })); + list["comments"].as_array().unwrap().iter().find(|c| c["type"] == kind).unwrap().clone() + }; + let rect = find(&mut a, "Square"); + assert_eq!((rect["opacity"].as_f64(), rect["fill_opacity"].as_f64(), rect.get("text_opacity")), (Some(1.0), Some(0.25), None)); + let tb = find(&mut a, "FreeText"); + assert_eq!((tb["opacity"].as_f64(), tb["fill_opacity"].as_f64(), tb["text_opacity"].as_f64()), (Some(1.0), Some(0.2), Some(1.0))); + // The fill changes, the text stays; an undo step. + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "fill_opacity": 0.5 })); + let tb = find(&mut a, "FreeText"); + assert_eq!((tb["fill_opacity"].as_f64(), tb["text_opacity"].as_f64()), (Some(0.5), Some(1.0))); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change comment properties"); + ok(&mut a, "edit_redo", json!({ "doc": doc })); + // The whole comment's opacity: everything at it again. + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "opacity": 0.6 })); + let rect = find(&mut a, "Square"); + assert_eq!((rect["opacity"].as_f64(), rect["fill_opacity"].as_f64()), (Some(0.6), Some(0.6))); + // Only the parts a comment has, at 0–1. + for (tool, args) in [ + ("comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [0, 0], "to": [50, 50], "fill_opacity": 0.5 })), + ("comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [80, 10, 120, 40], "text_opacity": 0.5 })), + ("comment_edit", json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "fill_opacity": 2 })), + ] { + assert!(matches!(a.call(tool, &args), Err(ToolError::InvalidArgs(_))), "{args}"); + } + assert!(matches!( + a.call("comment_edit", &json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "text_opacity": 0.5 })), + Err(ToolError::Failed(_)) + )); +} + #[test] fn comments_through_tools() { let dir = workdir("comments"); diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 40745f8c2a..c2cb1b3fd2 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -263,6 +263,15 @@ pub enum Edit { index: usize, fill: Option, }, + /// Draw a rectangle's, oval's, polygon's or text box's fill, or a text box's text, at an + /// opacity of its own (0–1), the line or border keeping the comment's; `None` leaves that + /// part as it is. `StyleAnnotation`'s opacity sets the whole comment's. + PartOpacity { + page: usize, + index: usize, + fill: Option, + text: Option, + }, /// Change the font size (pt) of a text box, typewriter text or callout. FontSizeAnnotation { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index e0ec47a2ba..e037ebc620 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -230,9 +230,11 @@ impl Edit { Edit::MoveAnnotation { .. } => Label::of("Move comment"), Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), - Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { - Label::of("Change comment properties") - } + Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::PartOpacity { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"), Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), Edit::ResetForm { .. } => Label::of("Clear form"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index c11904a74e..39c9ace3e0 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -64,6 +64,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } + | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } | Edit::SetMeasurementScale { .. } => { @@ -575,6 +576,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E )?; } Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, + Edit::PartOpacity { page, index, fill, text } => pdfcraft_annot::set_part_opacity(doc, *page, *index, *fill, *text, &cx.meta())?, Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, Edit::SetAnnotationInfo { page, index, author, subject, icon } => { pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 416cdc1d54..12c31dd7a6 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -68,6 +68,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } + | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Scope::Comments, Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6c54131d02..5d9e5e971d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2211,7 +2211,7 @@ fn rect_comment(page: usize, rect: [f64; 4]) -> Edit { Edit::AddAnnotation(NewAnnotation { page, shape: Shape::Rectangle { rect }, - style: Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) }, + style: Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]), ..Style::default() }, contents: "Look here".into(), author: "Reviewer".into(), }) diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 0df7d6f30b..5f9297c2ab 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -116,6 +116,8 @@ impl PdfCraftApp { "tool": t.command(), "color": s.color, "opacity": s.opacity, + "fill_opacity": s.fill_opacity, + "text_opacity": s.text_opacity, "width": s.width, "fill": s.fill, })) @@ -356,6 +358,8 @@ impl PdfCraftApp { if let Some(o) = e["opacity"].as_f64() { self.comment_prefs.set_opacity(tool, o); } + // The fill's and text's own opacities, where saved (older settings have none). + self.comment_prefs.set_part_opacity(tool, e["fill_opacity"].as_f64(), e["text_opacity"].as_f64()); if let Some(w) = e["width"].as_f64() { self.comment_prefs.set_width(tool, w); } diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 35ce510ae7..db08b7b918 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -108,8 +108,13 @@ impl PdfCraftApp { style.fill = p.fill; } self.comment_prefs.set_style(tool, style); + // The fill's and text's own opacities where they show (else the opacity is the whole + // comment's). With neither a border nor a fill the text is the whole comment: its drawn + // opacity, which Comment Properties shows, is the whole comment's default. + let (fill, text) = parts_shown(p); // Keep invisible defaults out of tools where a zero stroke would make them disappear. - self.comment_prefs.set_opacity(tool, p.opacity); + self.comment_prefs.set_opacity(tool, p.text_opacity.filter(|_| !text).unwrap_or(p.opacity)); + self.comment_prefs.set_part_opacity(tool, p.fill_opacity.filter(|_| fill), p.text_opacity.filter(|_| text)); if let Some(w) = p.width { self.comment_prefs.set_width(tool, w); } @@ -125,6 +130,20 @@ impl PdfCraftApp { } } +/// Which of the comment's parts show an opacity of their own: (its fill, its text). A text box's +/// text, border and fill each do once it has a border or a fill; a filled shape's line and fill +/// do; anything else has one opacity for the whole comment. +pub fn parts_shown(p: &CommentProps) -> (bool, bool) { + let fill = p.fill_opacity.is_some() && p.fill.is_some(); + let text = p.text_opacity.is_some() && (fill || has_border(p)); + (fill, text) +} + +/// A text box's, typewriter text's or callout's border is drawn. +fn has_border(p: &CommentProps) -> bool { + p.font_size.is_some() && p.width.is_some_and(|w| w > 0.0) +} + /// The edits that make the comment match the draft (empty when nothing changed). pub fn edits(d: &PropsDraft) -> Vec { let (o, e) = (&d.original, &d.edited); @@ -143,6 +162,15 @@ pub fn edits(d: &PropsDraft) -> Vec { if e.restylable && e.fillable && e.fill != o.fill { out.push(Edit::FillAnnotation { page: d.page, index: d.index, fill: e.fill }); } + // After the opacity, which is the whole comment's: the parts shown are set again. + let (fill, text) = parts_shown(e); + let moved = |a: Option, b: Option| a.zip(b).is_some_and(|(a, b)| (a - b).abs() > 1e-6); + if e.restylable + && (fill || text) + && (opacity.is_some() || fill && moved(e.fill_opacity, o.fill_opacity) || text && moved(e.text_opacity, o.text_opacity)) + { + out.push(Edit::PartOpacity { page: d.page, index: d.index, fill: e.fill_opacity.filter(|_| fill), text: e.text_opacity.filter(|_| text) }); + } if e.restylable && let Some(size) = e.font_size.filter(|s| Some(*s) != o.font_size) { @@ -231,12 +259,51 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.add(egui::DragValue::new(size).range(1.0..=400.0).speed(0.5).suffix(" pt")); ui.end_row(); } - ui.label(tl!("Opacity")); - let mut pct = e.opacity * 100.0; - if ui.add(egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).changed() { - e.opacity = pct / 100.0; + // One opacity for the whole comment, or each part's of its own: a text box's + // text, border and fill, a shape's line and fill. The rows stay put; a part + // not drawn (no fill, no border) is greyed. + let percent = |ui: &mut egui::Ui, label: &str, value: &mut f64, enabled: bool| { + let name = ui.add_enabled(enabled, egui::Label::new(label)); + let mut pct = *value * 100.0; + if ui.add_enabled(enabled, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).labelled_by(name.id).changed() { + *value = pct / 100.0; + } + ui.end_row(); + }; + let (fill, text) = parts_shown(e); + let border = has_border(e); + match (e.text_opacity.is_some(), e.fill_opacity.is_some()) { + (true, _) => { + // With neither a border nor a fill, the text is the whole comment: + // shown at the opacity it is drawn at, a change sets the whole + // comment's (which its parts then take). + match e.text_opacity.as_mut() { + Some(t) if text => percent(ui, tl!("Text opacity"), t, true), + drawn => { + let mut shown = drawn.as_deref().copied().unwrap_or(e.opacity); + let before = shown; + percent(ui, tl!("Text opacity"), &mut shown, true); + if shown != before { + e.opacity = shown; + if let Some(t) = drawn { + *t = shown; + } + } + } + } + if text { + percent(ui, tl!("Border opacity"), &mut e.opacity, border); + } else { + let mut whole = e.opacity; + percent(ui, tl!("Border opacity"), &mut whole, false); + } + } + (false, true) => percent(ui, tl!("Line opacity"), &mut e.opacity, true), + (false, false) => percent(ui, tl!("Opacity"), &mut e.opacity, true), + } + if let Some(f) = e.fill_opacity.as_mut() { + percent(ui, tl!("Fill opacity"), f, fill); } - ui.end_row(); let min_width = if e.font_size.is_some() { 0.0 } else { 0.5 }; if let Some(w) = e.width.as_mut() { ui.label(tl!("Thickness")); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 879025b01d..8597e07b5a 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -904,6 +904,7 @@ fn comment_page(edit: &Edit) -> Option { | Edit::ResizeAnnotation { page, .. } | Edit::StyleAnnotation { page, .. } | Edit::FillAnnotation { page, .. } + | Edit::PartOpacity { page, .. } | Edit::FontSizeAnnotation { page, .. } | Edit::SetAnnotationInfo { page, .. } => Some(*page), _ => None, diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 5d945136e7..348d4b906f 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -353,6 +353,15 @@ impl CommentPrefs { } } + /// The tool's fill's own opacity (0–100 %) and a text box's or callout's text's (10–100 %), + /// for new comments; `None`: as the tool's opacity. + pub fn set_part_opacity(&mut self, tool: CommentTool, fill: Option, text: Option) { + if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { + s.fill_opacity = fill.filter(|o| o.is_finite() && tool.has_fill()).map(|o| o.clamp(0.0, 1.0)); + s.text_opacity = text.filter(|o| o.is_finite() && matches!(tool, CommentTool::TextBox | CommentTool::Callout)).map(|o| o.clamp(0.1, 1.0)); + } + } + /// The tool's fill (`None`: no fill). pub fn set_fill(&mut self, tool: CommentTool, fill: Option) { if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 0e0da5caf3..58252c393a 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -327,8 +327,10 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m ui.set_min_width(180.0); let mut percent = (style.opacity * 100.0).round(); ui.label(egui::RichText::new(tl!("Opacity")).font(theme::semibold(12.0))); + // The whole comment's: its fill and text take it too. if ui.add(egui::Slider::new(&mut percent, 10.0..=100.0).step_by(5.0).suffix(" %")).changed() { prefs.set_opacity(tool, percent / 100.0); + prefs.set_part_opacity(tool, None, None); } }); if tool.has_width() { diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 674cd90d26..444db23c84 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1360,6 +1360,91 @@ fn shapes_take_a_fill_colour_from_the_quick_bar_and_properties() { assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill, Some([0.0, 0.0, 1.0])); } +/// A filled rectangle's line and fill, and a text box's text, border and fill, each take an +/// opacity of their own in Properties; Make Properties Default carries them into the tool, so +/// the next rectangle is drawn with a see-through fill and an opaque line. +#[test] +fn comment_properties_set_each_parts_opacity() { + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + h.state_mut().comment_prefs.set_fill(CommentTool::Rectangle, Some([1.0, 1.0, 0.0])); + h.run_steps(2); + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + let props = |h: &Harness<'static, PdfCraftApp>, index: usize| { + let s = h.state(); + let p = s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap(); + (p.opacity, p.fill_opacity, p.text_opacity) + }; + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + // The sliders (each labelled by its row, which shares the name). + h.get_by_role_and_label(egui::accesskit::Role::Slider, "Line opacity"); + h.get_by_role_and_label(egui::accesskit::Role::Slider, "Fill opacity"); + assert_eq!(h.query_all_by_label("Text opacity").count(), 0, "a rectangle has no text"); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + d.edited.fill_opacity = Some(0.3); + d.make_default = true; + } + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(props(&h, 0), (1.0, Some(0.3), None)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Change comment properties")); + assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill_opacity, Some(0.3)); + h.state_mut().set_option("quick", "square").unwrap(); + h.run_steps(2); + drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0)); + assert_eq!(props(&h, 1), (1.0, Some(0.3), None), "the next rectangle takes the default"); + + // A text box with a border and a fill: its text, border and fill. + let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 }; + let mut style = pdfcraft_engine::Style::default_for(&shape); + (style.width, style.fill) = (1.0, Some([1.0, 1.0, 0.6])); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: "Note".into(), + author: "Tester".into(), + }))); + h.state_mut().set_option("quick", "select").unwrap(); + h.run_steps(2); + h.state_mut().open_comment_props(0, 2); + h.run_steps(2); + for label in ["Text opacity", "Border opacity", "Fill opacity"] { + h.get_by_role_and_label(egui::accesskit::Role::Slider, label); + } + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + let _gpu = crate::gpu(); + h.render().unwrap().save(format!("{dir}/comment-part-opacity.png")).unwrap(); + } + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + (d.edited.opacity, d.edited.fill_opacity) = (0.5, Some(0.2)); + } + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(props(&h, 2), (0.5, Some(0.2), Some(1.0)), "the border at half, the fill at a fifth, the text opaque"); + // Without its fill and border, the text is the whole text box: its opacity is the comment's. + h.state_mut().open_comment_props(0, 2); + h.run_steps(2); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + (d.edited.fill, d.edited.width) = (None, Some(0.0)); + } + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + h.state_mut().open_comment_props(0, 2); + h.run_steps(2); + let shown = egui_kittest::kittest::NodeT::accesskit_node(&h.get_by_role_and_label(egui::accesskit::Role::Slider, "Text opacity")).numeric_value(); + assert_eq!(shown, Some(100.0), "the text's slider shows the opacity it is drawn at, not the border's half"); + h.state_mut().comment_props.as_mut().expect("open").edited.opacity = 0.4; + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(props(&h, 2), (0.4, Some(0.4), Some(0.4))); +} + #[test] fn the_panel_filters_and_sorts() { let mut h = harness(|app| { @@ -2247,6 +2332,43 @@ fn properties_dialog_makes_the_strikethrough_colour_default() { assert!(made.iter().zip(green).all(|(m, r)| (f64::from(*m) - r).abs() < 0.01), "the next strikethrough is green: {made:?}"); } +/// A text box with neither a border nor a fill is all text: Make Properties Default (and the +/// context menu's Make Current Properties Default) gives the tool the opacity its text is drawn +/// at, the one Comment Properties shows, not the comment's /CA. +#[test] +fn a_text_only_text_box_made_default_gives_the_tool_its_texts_opacity() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 }; + let style = Style { width: 0.0, fill: None, ..Style::default_for(&shape) }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "Faint".into(), + author: "Tester".into() + }))); + h.run_steps(2); + let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Faint")).map(|a| a.index).expect("the text box"); + // As the automation's comment_edit sets it. + assert!(h.state_mut().apply_edit(Edit::PartOpacity { page: 0, index, fill: None, text: Some(0.5) })); + h.run_steps(2); + let opacity = |h: &Harness<'static, PdfCraftApp>| h.state().comment_prefs.style(CommentTool::TextBox).opacity; + assert!((opacity(&h) - 1.0).abs() < 1e-6); + h.state_mut().open_comment_props(0, index); + h.run_steps(2); + h.get_by_label("Make Properties Default").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h)); + // The context menu's way, from a tool at 100 % again. + h.state_mut().comment_prefs.set_opacity(CommentTool::TextBox, 1.0); + h.state_mut().make_comment_default(0, index); + assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h)); +} + /// #1302: making an arrow's properties the default updates the Arrow tool, not later plain lines. #[test] fn an_arrows_default_does_not_restyle_lines() { @@ -2988,7 +3110,14 @@ fn a_moved_comment_is_drawn_where_it_goes_before_release() { for shape in [pdfcraft_engine::Shape::Rectangle { rect: [30.0, 30.0, 70.0, 70.0] }, pdfcraft_engine::Shape::Oval { rect: [230.0, 30.0, 270.0, 70.0] }] { - let style = pdfcraft_engine::Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) }; + let style = pdfcraft_engine::Style { + color: [1.0, 0.0, 0.0], + opacity: 1.0, + width: 2.0, + fill: Some([1.0, 0.0, 0.0]), + fill_opacity: None, + text_opacity: None, + }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page: 0, shape, diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index c439a51943..12989e9e73 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -911,6 +911,28 @@ fn comment_style_settings_clamp_and_refuse_hostile_values() { assert_eq!(note.color, [1.0, 0.82, 0.0], "the default colour stays"); } +/// A tool's fill's and text's own opacities survive a restart, clamped; a tool without a fill or +/// text keeps none, and the quick bar's Opacity (the whole comment's) clears them. +#[test] +fn part_opacity_defaults_persist_and_clamp() { + use pdfcraft_ui_egui::comments::CommentTool; + let mut a = PdfCraftApp::new(); + a.comment_prefs.set_part_opacity(CommentTool::Rectangle, Some(0.3), None); + a.comment_prefs.set_part_opacity(CommentTool::TextBox, Some(0.2), Some(1.0)); + a.comment_prefs.set_part_opacity(CommentTool::Line, Some(0.5), Some(0.5)); + let mut b = PdfCraftApp::new(); + b.restore(&a.persist()); + let parts = |app: &PdfCraftApp, tool| { + let s = app.comment_prefs.style(tool); + (s.fill_opacity, s.text_opacity) + }; + assert_eq!(parts(&b, CommentTool::Rectangle), (Some(0.3), None)); + assert_eq!(parts(&b, CommentTool::TextBox), (Some(0.2), Some(1.0))); + assert_eq!(parts(&b, CommentTool::Line), (None, None), "a line has no fill or text of its own"); + b.restore(r#"{"comment_styles":[{"tool":"comment.freetext","fill_opacity":7,"text_opacity":-1}]}"#); + assert_eq!(parts(&b, CommentTool::TextBox), (Some(1.0), Some(0.1)), "clamped; the text stays visible"); +} + #[test] fn zoom_keeps_the_point_under_the_cursor_still() { let mut h = harness(|app| { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 0da17c1e67..52dbe0369b 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -149,7 +149,7 @@ fn values(doc: &Document) -> Vec<(String, Vec)> { fn a_replace_text_pair_survives_an_xfdf_round_trip() { let mut src = blank(); let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; - let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; + let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() }; let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() }; add_text_replacement( &mut src, diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 69072e259c..cbb1dcab06 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it; a comment's text, border or line, and fill each at an opacity of its own) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the 2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is @@ -76,7 +76,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Print dialog with preview | yes | yes | shipped | | Document Properties (description, security, fonts, initial view, custom, advanced) | yes | all but Custom and XMP | partial | | Field Properties (General, Appearance, Position, Options, Actions, Format, Validate, Calculate, Signed) | yes | all but Signed | partial | -| Comment properties and properties bar | yes | yes | shipped | +| Comment properties and properties bar | yes | yes; a text box's text, border and fill and a shape's line and fill each take an opacity of their own (Acrobat: one per comment) | shipped | | Preferences | ≈ 20 pages | Interface language, Documents and view, Identity, Fill & Sign, JavaScript | partial (gap 19) | ## Revision history @@ -84,6 +84,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Date | Change | Summary | |---|---|---| | 2026-10-24 | minor | → / ← scroll a page wider than the window sideways before turning it, by the current page's or spread's edges in continuous view; ⌘→ / ⌘← turn at once | +| 2026-10-24 | minor | Comment Properties sets a text box's text, border and fill opacity and a shape's line and fill opacity each on its own (Acrobat: one per comment); Make Properties Default carries them into the tool | | 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. | | 2026-10-15 | minor | A selected field nudges with the arrow keys in Prepare a form: a point, Shift ten points, one undo step per press (#2608) | | 2026-10-14 | minor | Full screen shows one fitted page (or spread) at a time on black with click and key paging, as Acrobat's Full Screen Mode (#379, #1459, #1353) | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1a3edc4c36..5cb6e1c56a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3965,6 +3965,18 @@ tools = ["comment_edit"] evidence = ["crates/ui-egui/tests/it/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/it/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/it/comments.rs::text_box_properties_change_the_font_size", "crates/ui-egui/tests/it/comments.rs::text_box_and_callout_properties_include_fill_and_thickness", "crates/annot/src/tests.rs::text_boxes_and_callouts_can_change_fill_and_border", "crates/automation/tests/it/automation.rs::text_box_and_callout_fill_and_border_through_tools", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/it/automation.rs::text_box_font_size_through_the_tool"] notes = "Appearance (colour, fill colour for rectangles/ovals/polygons/text boxes/callouts, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)" +[[feature]] +id = "comment.part-opacity" +title = "A comment's text, border or line, and fill each at an opacity of its own" +area = "E" +tier = "P1" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_add", "comment_edit", "comment_list"] +evidence = ["crates/annot/src/tests.rs::parts_are_drawn_at_opacities_of_their_own", "crates/ui-egui/tests/it/comments.rs::comment_properties_set_each_parts_opacity", "crates/automation/tests/it/automation.rs::comment_parts_take_opacities_of_their_own_through_the_tools", "crates/ui-egui/tests/it/ui.rs::part_opacity_defaults_persist_and_clamp"] +notes = "Comment Properties shows an opacity for each part instead of one for the whole comment: a text box, typewriter text or callout its Text, Border and Fill opacity, a rectangle, oval, polygon or cloud its Line and Fill opacity (a part that isn't drawn, such as an unset fill, is greyed); lines, drawings and the rest keep one Opacity. A see-through fill with an opaque line marks an area on a plan without hiding it. Stored as the line's /CA, the fill's /ca (PDF 2.0's opacity for what is filled) and the text's /PCTextOpacity (PdfCraft's own), each only where it differs, and drawn with two graphics states in the appearance, so every viewer shows it; an app that redraws the comment from its keys (Acrobat after editing it) uses one opacity again. The quick bar's Opacity and comment_edit `opacity` set the whole comment's; Make Properties Default carries the parts into the tool (saved with the settings). Headless: comment_add / comment_edit `fill_opacity`, `text_opacity`; comment_list reports `opacity`, `fill_opacity`, `text_opacity`. Acrobat has one opacity per comment." + [[feature]] id = "comment.properties-bar" title = "Comment properties bar" From 6427357fcb184ef4da93e78b3dda6d16bd7a0ab5 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Thu, 5 Nov 2026 21:12:04 +0600 Subject: [PATCH 10/43] Forms: date fields name months in the date language, not always English (#5303) (#5464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Forms: date fields name months in the date language, not always English (#5303) `AFDate_FormatEx("dd-mmm-yy")` wrote "18-Oct-26" in an Italian interface, where a localized Acrobat writes "18-ott-26". Form date formats, `util.printd` and `util.scand` now take their month and weekday names from the date language (Preferences > Date format > Language, which follows the interface language until one is picked), and still read English names typed in. The names are process-wide, so their test runs in its own binary. Co-Authored-By: Claude Opus 5.5 * Forms: dates read back in every date language (#5303) Trying every date language showed what a field wrote not always reading back: names holding digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs (Thai, Hindi, Telugu) fell apart word by word, and Italian, Spanish, Romanian and Albanian share some short weekday and month names ("mar"). The language's names are now found whole in the text first, the longest at each place, and read as their English names; the format's order of `mmm` and `ddd` settles a shared name. Both AFDate parsing and util.scand do this, on text up to 1 KB. A test reads back every language, each name format, and hostile text. Co-Authored-By: Claude Opus 5.5 * Forms: date names no field font can draw are drawn in English (#5303) With the names in the date language, a Russian dd-mmm-yy field drawn in Helvetica showed "18-????.-26": no /DR font held "окт." and the WinAnsi stand-in drew each letter as ?. The same held for Japanese, Chinese, Greek, Arabic, Thai and others, and for Latin names outside WinAnsi (Czech "říj"). Before, the names were English and always drew. An AFDate/AFTime field now formats its value again with English names for a widget whose fonts can't draw the localized text; a custom Format script is run again with util.printd writing English names for such a widget, and its result kept only when it draws whole. A field whose font holds the names keeps them, and scripts otherwise still print the date language. Co-Authored-By: Claude Opus 5.5 * Forms: an English weekday before the month keeps the month (#5303) With Italian dates, "Tue 17 Mar 2026" in a "ddd d mmm yyyy" field read as no date: the language's names were found in the text, but not English ones, so "Tue" left the format's weekday unfilled and "Mar", which Italian shares between Tuesday and March, was taken as the weekday. Both AFDate parsing and util.scand lost the month, and Spanish, Romanian and Albanian ("mar") did the same. English names are now found too and fill their field of the format as localized ones do. A test reads English and mixed weekdays before the month in those languages. Co-Authored-By: Claude Opus 5.5 * Forms: an English date run is drawn even when it prints the value (#5303) A Helvetica field storing "18-Oct-26" whose Format script prints it again as dd-mmm-yy still showed "18-???.-26" in Russian. The English run printed "18-Oct-26", but it was dropped for equalling the stored value, which was how a failed run was told apart: a failing script handed back the value unchanged. The English run now says whether it ran (`None` when the script failed or the runner can't write English dates), and what it made is drawn whenever it draws whole. A test reprints the stored value under Russian, and checks that a failing script keeps its text and is reported undrawn. Co-Authored-By: Claude Opus 5.5 * Forms: a short weekday typed without its dot is the weekday (#5303) With French dates, "mar 6 janv 2026" in a "ddd d mmm yyyy" field read as no date through util.scand, and "mar 06/01/2026" in "ddd dd/mm/yyyy" through AFDate too. French writes Tuesday as "mar.", which a name search only found with its dot; since English names are found as well, the English "Mar" (March) was the only name of that length, so the weekday became a month. Romanian "mar" did the same. A short name ending in a dot is now also found typed without it, as the same kind of name, so the format's order of name fields decides between it and an equal name of the other kind. Both copies of the name search change. A test reads French and Romanian weekdays typed without their dot. Co-Authored-By: Claude Opus 5.5 * Forms: a shared name is the month when no weekday is typed (#5303) With Italian dates, "17 mar 2026" in a "ddd d mmm yyyy" field read as no date, nor did "mar 17, 2026" in "dddd, mmmm d, yyyy". AFDate leaves weekdays out of what it reads (they are shown, not typed), but the name search still expected the format's weekday first, so "mar", which Italian shares between Tuesday and March, filled it and left no month. Spanish and Albanian "mar" and Albanian "sht" (Saturday, September) did the same, and the keystroke check refused those dates. Where such a read finds no date, the names are now read again with no weekday expected, so the shared name is the month; a typed weekday still comes first ("mar 17 mar 2026"). util.scand reads its fields in order and needs the weekday, so it is unchanged. A test types those dates with the weekday left out. Co-Authored-By: Claude Opus 5.5 * Forms: Turkish names typed in capitals are read (#5303) With Turkish dates, "1 MAYIS 2026" and "1 EKİM 2026" in a d mmmm yyyy field read as no date through AFDate and util.scand, and "SALI 6 OCAK 2026" through util.scand. Names were compared lower-cased, which turns I into i, not the dotless ı of "Mayıs" and "Salı", and İ into i with a combining dot above, which "Ekim" doesn't have. Names and typed text are now folded alike, the capital I and the dotted and dotless i all as i and the combining dot dropped, one piece for each char so positions in the text stay aligned: in both copies of the name search, the AFDate month reader and util.scand's name matcher. English reads as before. A test types Turkish names in capitals and English ones as a check. Co-Authored-By: Claude Opus 5.5 * Forms: Greek names typed in capitals are read again (#5303) Folding names char by char turned a capital Σ into σ, so "ΙΑΝΟΥΆΡΙΟΣ" no longer matched "Ιανουάριος", whose last letter is the final ς. The fold now makes ς a σ on both sides, in the forms and JS parsers alike. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/engine/src/dates.rs | 12 + crates/engine/src/js.rs | 31 +- crates/engine/tests/form_date_language.rs | 453 ++++++++++++++++++++++ crates/forms/src/af.rs | 5 +- crates/forms/src/af/format.rs | 202 +++++++++- crates/forms/src/appearance.rs | 19 +- crates/forms/src/scripting.rs | 22 ++ crates/forms/src/values.rs | 17 +- crates/js/src/format.rs | 195 +++++++++- crates/js/src/lib.rs | 1 + crates/js/src/run.rs | 4 +- crates/ui-egui/src/app.rs | 3 + 12 files changed, 932 insertions(+), 32 deletions(-) create mode 100644 crates/engine/tests/form_date_language.rs diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index c9382c1b02..5ede67c811 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -582,6 +582,18 @@ pub fn date_language(code: &str) -> Option<&'static DateLanguage> { DATE_LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code.trim())) } +/// Form date fields (`AFDate_FormatEx`, `util.printd`, `util.scand`) write month and weekday +/// names in date language `code` from now on, as a localized Acrobat writes its own language's: +/// `dd-mmm-yy` is `18-ott-26` in Italian (#5303). An unknown code is English. +pub fn use_form_date_language(code: &str) { + let l = date_language(code).unwrap_or(&DATE_LANGUAGES[0]); + let names = pdfcraft_forms::af::DateNames { months: l.months, months_short: l.months_short, days: l.days, days_short: l.days_short }; + if pdfcraft_forms::af::date_names() != names { + pdfcraft_forms::af::set_date_names(names); + pdfcraft_js::set_date_names(l.months, l.months_short, l.days, l.days_short); + } +} + /// The characters of `text` that Fill & Sign can't write into a PDF yet, each once. Its text is /// drawn in Helvetica with WinAnsi encoding (Western European letters), so anything else (`ř`, /// Japanese, Chinese) would become `?` in the file. diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index d1ee5c15b0..cc78785815 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -199,6 +199,24 @@ impl Scripts for JsRunner { /// Run field `target`'s script for `event` over the form's fields, keeping what it showed and /// asked for; a refusal carries its last message. fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult { + // As in Acrobat, a failing script leaves the value alone (the error goes to the console). + self.run_logged(event, script, target, value, fields, true).unwrap_or_else(|| ScriptResult { + rc: true, + value: value.to_string(), + changes: Vec::new(), + message: None, + }) + } + + fn rerun_with_english_dates(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> Option { + pdfcraft_js::with_english_printd(true, || self.run_logged(event, script, target, value, fields, false)) + } +} + +impl JsRunner { + /// [`Scripts::run`], keeping what the script showed and asked for only when `log`; `None` + /// when the script failed. + fn run_logged(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], log: bool) -> Option { let states: Vec<_> = fields .iter() .map(|f| { @@ -212,17 +230,18 @@ impl Scripts for JsRunner { let states = std::sync::Arc::new(states); let ev = pdfcraft_js::Event::field(event.name(), &target.name, value); let o = pdfcraft_js::run_shared(script, &ev, &self.doc, std::sync::Arc::clone(&states), &self.doc_scripts, Limits::default()); - self.output.absorb(&o); - if o.error.is_some() { - // As in Acrobat, a failing script leaves the value alone (the error goes to the console). - return ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None }; + if log { + self.output.absorb(&o); } - ScriptResult { + if o.error.is_some() { + return None; + } + Some(ScriptResult { rc: o.rc, value: o.value.clone(), changes: changes(&states, &o.changed), message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), - } + }) } } diff --git a/crates/engine/tests/form_date_language.rs b/crates/engine/tests/form_date_language.rs new file mode 100644 index 0000000000..1d4293e12c --- /dev/null +++ b/crates/engine/tests/form_date_language.rs @@ -0,0 +1,453 @@ +//! Form date fields name months in the app's date language (#5303). Its own test binary: the +//! names are process-wide, so other tests must not see them change, and its tests take turns. + +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use pdfcraft_cos::{Document, Object}; +use pdfcraft_engine::dates::use_form_date_language; +use pdfcraft_engine::js::JsRunner; +use pdfcraft_forms::FieldValue; +use pdfcraft_forms::af::{DateTime, Format, format_date, format_value, keystroke, parse_date}; +use pdfcraft_js::{DocInfo, Event, Limits, run}; + +/// Held by each test while it changes the process-wide date names, which start as English (a +/// failed test may have left others). +fn names_lock() -> MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + let lock = LOCK.lock().unwrap_or_else(PoisonError::into_inner); + use_form_date_language("en"); + lock +} + +/// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads. +fn printd(fmt: &str, typed: &str) -> String { + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));"); + let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); + assert_eq!(o.error, None); + o.value +} + +#[test] +fn form_dates_follow_the_date_language() { + let _names = names_lock(); + let oct18 = DateTime { y: 2026, m: 10, d: 18, hh: 0, mm: 0, ss: 0 }; + let dd_mmm_yy = Format::Date("dd-mmm-yy".into()); + // English until the app says otherwise. + assert_eq!(format_date(oct18, "dd-mmm-yy dddd ddd mmmm"), "18-Oct-26 Sunday Sun October"); + use_form_date_language("it"); + // AFDate_FormatEx("dd-mmm-yy"), what the issue's form used. + assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-ott-26"); + assert_eq!(format_date(oct18, "dddd d mmmm yyyy, ddd"), "domenica 18 ottobre 2026, dom"); + // What it writes reads back, and so does English typing. + assert_eq!(parse_date("18-ott-26", "dd-mmm-yy"), Some(oct18)); + assert_eq!(parse_date("18 ottobre 2026", "dd mmmm yyyy"), Some(oct18)); + assert_eq!(parse_date("18-Oct-26", "dd-mmm-yy"), Some(oct18)); + assert!(keystroke(&dd_mmm_yy, "date", "18-ott-26").is_ok()); + // util.printd writes Italian names; util.scand reads them. + assert_eq!(printd("dd-mmm-yy", "18-ott-26"), "18-ott-26|18-ott-26"); + assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-ott-26|18-ott-26", "English typing reads too"); + assert_eq!(printd("dddd d mmmm yyyy", "domenica 18 ottobre 2026"), "domenica 18 ottobre 2026|domenica 18 ottobre 2026"); + // German short names end in a dot; they are written and read whole. + use_form_date_language("de"); + assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Okt.-26"); + assert_eq!(parse_date("18-Okt.-26", "dd-mmm-yy"), Some(oct18)); + // An unknown language is English again. + use_form_date_language("xx"); + assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Oct-26"); + assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-Oct-26|18-Oct-26"); + every_date_language_reads_back_what_it_writes(); + // Hostile typing never panics and stays quick: long, odd and mixed-script text. + use_form_date_language("th"); + let junk = ["", " ", "ม.", "ม.ค.ม.ค.ม.ค.", "\u{0}", "1月1月1月", "tháng tháng", "Ottobre", "🙂ott🙂", "İstanbul ott 26"]; + for t in junk.iter().map(|s| s.to_string()).chain(["ม.ค.1".repeat(5000), "9".repeat(100_000)]) { + let started = std::time::Instant::now(); + let _ = parse_date(&t, "dd-mmm-yy"); + let _ = keystroke(&dd_mmm_yy, "date", &t); + assert!(started.elapsed() < std::time::Duration::from_secs(2), "{} chars took {:?}", t.len(), started.elapsed()); + } + use_form_date_language("en"); +} + +/// An English weekday typed before the month fills the format's weekday, so a month name that +/// the date language shares with a weekday (Italian, Spanish, Romanian and Albanian "mar") is +/// still read as the month: "Tue 17 Mar 2026" read as "Tue 17 Tuesday 2026" (#5303). +#[test] +fn an_english_weekday_before_a_shared_month_name_reads_as_english() { + let _names = names_lock(); + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 }; + let mut bad = Vec::new(); + for language in ["it", "es", "ro", "sq"] { + use_form_date_language(language); + let ddd_d_mmm = "ddd d mmm yyyy"; + let typed = [ + // What the language writes, then English, then English and localized mixed. + (ddd_d_mmm, format_date(mar17, ddd_d_mmm)), + (ddd_d_mmm, "Tue 17 Mar 2026".into()), + (ddd_d_mmm, "Tuesday 17 Mar 2026".into()), + (ddd_d_mmm, "tue 17 mar 2026".into()), + (ddd_d_mmm, format!("Tue 17 {}", format_date(mar17, "mmm yyyy"))), + (ddd_d_mmm, format!("{} 17 Mar 2026", format_date(mar17, "ddd"))), + ("mmm d yyyy ddd", "Mar 17 2026 Tue".into()), + ("dddd d mmmm yyyy", "Tuesday 17 March 2026".into()), + ]; + for (fmt, text) in typed { + if parse_date(&text, fmt) != Some(mar17) { + bad.push(format!("{language} forms {fmt}: {text:?} -> {:?}", parse_date(&text, fmt))); + } + let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';"); + let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); + if o.value != "2026-03-17" { + bad.push(format!("{language} scand {fmt}: {text:?} -> {:?} {:?}", o.value, o.error)); + } + } + } + use_form_date_language("en"); + assert!(bad.is_empty(), "{}", bad.join("; ")); +} + +/// Every date language: what a date field writes reads back as the same date, for each date +/// letter that writes a name, through the form path and through `util.scand`. Called from the +/// one test, which owns the process-wide names. +fn every_date_language_reads_back_what_it_writes() { + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let mut bad = Vec::new(); + for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() { + use_form_date_language(l.code); + for m in 1..=12u32 { + let dt = DateTime { y: 2026, m, d: 18, hh: 0, mm: 0, ss: 0 }; + for fmt in ["dd-mmm-yy", "d mmmm yyyy", "dddd, mmmm d, yyyy", "ddd d mmm yyyy", "mmm d, yyyy ddd", "yyyymmmdd"] { + let text = format_date(dt, fmt); + // A short month ending in a number ("Thg 1") glued to the day is ambiguous + // ("Thg 118": January 18 or November 8): nothing can read it back. + if fmt == "yyyymmmdd" && l.months_short.iter().any(|n| n.ends_with(|c: char| c.is_ascii_digit())) { + continue; + } + if parse_date(&text, fmt).map(|p| (p.y, p.m, p.d)) != Some((2026, m, 18)) { + bad.push(format!("{} forms {fmt}: {text:?}", l.code)); + } + let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';"); + let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); + if o.value != format!("2026-{m:02}-18") { + bad.push(format!("{} scand {fmt}: {text:?} -> {:?} {:?}", l.code, o.value, o.error)); + } + } + } + } + use_form_date_language("en"); + assert!(bad.is_empty(), "{} failures:\n{}", bad.len(), bad.iter().take(40).cloned().collect::>().join("\n")); +} + +/// A form whose text fields are each `(name, font, format script)`: `/Helv` is Helvetica with +/// WinAnsi, `/Song` a Chinese font with a Unicode CMap, which also holds Cyrillic. +fn form(fields: &[(&str, &str, &str)]) -> Document { + let n = fields.len(); + let first = 6; + let refs = |from: usize| (from..from + n).map(|i| format!("{i} 0 R")).collect::>().join(" "); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", refs(first)), + format!("<< /Fields [{}] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /Song {} 0 R >> >> >>", refs(first), first + n), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + for (i, (name, font, js)) in fields.iter().enumerate() { + let js = js.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)"); + let y = 700 - 30 * i; + objs.push(format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA (/{font} 10 Tf 0 g) /Rect [50 {y} 300 {}] /P 3 0 R /AA << /F << /S /JavaScript /JS ({js}) >> >> >>", + y + 20 + )); + } + objs.push("<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>] >>".into()); + let mut out = b"%PDF-1.7 +" + .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 +{o} +endobj +", + i + 1 + ) + .as_bytes(), + ); + } + let xref = out.len(); + out.extend_from_slice( + format!( + "xref +0 {} +0000000000 65535 f +", + objs.len() + 1 + ) + .as_bytes(), + ); + for o in offsets { + out.extend_from_slice( + format!( + "{o:010} 00000 n +" + ) + .as_bytes(), + ); + } + out.extend_from_slice( + format!( + "trailer +<< /Size {} /Root 1 0 R >> +startxref +{xref} +%%EOF +", + objs.len() + 1 + ) + .as_bytes(), + ); + Document::open(Arc::new(out)).expect("opens") +} + +/// The content of field `name`'s normal appearance. +fn appearance(doc: &Document, name: &str) -> Vec { + let all = pdfcraft_forms::fields(doc); + let f = all.iter().find(|f| f.name == name).expect("field"); + let widget = doc.get(f.widgets[0].obj); + let normal = widget.as_dict().and_then(|d| d.get(b"AP")).and_then(|a| doc.resolve(a).as_dict().and_then(|a| a.reference(b"N"))).expect("/AP /N"); + match &*doc.get(normal) { + Object::Stream(s) => s.decoded().expect("decodes"), + other => panic!("not a stream: {other:?}"), + } +} + +/// What a Unicode-CMap (UCS-2) font is given for `text`. +fn ucs2(text: &str) -> Vec { + text.encode_utf16().flat_map(u16::to_be_bytes).collect() +} + +fn contains(hay: &[u8], needle: &[u8]) -> bool { + hay.windows(needle.len()).any(|w| w == needle) +} + +/// Fills `form`'s field `name` with 18 October 2026 through the JavaScript runner. +fn fill(doc: &mut Document, name: &str) { + fill_with(doc, name, "18-10-26"); +} + +/// Fills `form`'s field `name` with `value` through the JavaScript runner. +fn fill_with(doc: &mut Document, name: &str, value: &str) { + let mut js = JsRunner::new(doc, "form.pdf"); + pdfcraft_forms::set_value_with(doc, name, &FieldValue::Text(value.into()), &mut js).expect("fills"); +} + +/// Month names in the date language that no font of the field can draw are drawn in English, +/// never as `?` ("18-????.-26" in Russian); a font that holds them keeps them (#5303). +#[test] +fn date_names_no_field_font_can_draw_are_drawn_in_english() { + let _names = names_lock(); + let date = "AFDate_FormatEx(\"dd-mmm-yy\");"; + let fields = [("helv", "Helv", date), ("song", "Song", date)]; + for (language, local) in [("ru", "окт."), ("cs", "říj"), ("ja", "10月"), ("el", "Οκτ")] { + use_form_date_language(language); + let mut doc = form(&fields); + fill(&mut doc, "helv"); + fill(&mut doc, "song"); + let helv = appearance(&doc, "helv"); + assert!(contains(&helv, b"(18-Oct-26) Tj"), "{language}: {}", String::from_utf8_lossy(&helv)); + assert!(!helv.contains(&b'?'), "{language}: {}", String::from_utf8_lossy(&helv)); + assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty(), "{language}"); + let song = appearance(&doc, "song"); + assert!(contains(&song, &ucs2(&format!("18-{local}-26"))), "{language} keeps its name: {song:?}"); + } + // Names Helvetica holds are kept: Italian "ott". + use_form_date_language("it"); + let mut doc = form(&fields); + fill(&mut doc, "helv"); + assert!(contains(&appearance(&doc, "helv"), b"(18-ott-26) Tj")); + use_form_date_language("en"); +} + +/// A Format script's `util.printd` is run again with English names for a field whose fonts +/// can't draw the date language's, and kept where they can (#5303). +#[test] +fn format_scripts_print_dates_in_english_where_the_field_cannot_draw_them() { + let _names = names_lock(); + let script = "event.value = event.value ? util.printd(\"dd-mmm-yy\", util.scand(\"dd-mm-yy\", event.value)) + \" !\" : \"\";"; + let fields = [("helv", "Helv", script), ("song", "Song", script)]; + use_form_date_language("ru"); + let mut doc = form(&fields); + fill(&mut doc, "helv"); + fill(&mut doc, "song"); + let helv = appearance(&doc, "helv"); + assert!(contains(&helv, b"(18-Oct-26 !) Tj"), "{}", String::from_utf8_lossy(&helv)); + assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv)); + assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty()); + assert!(contains(&appearance(&doc, "song"), &ucs2("18-окт.-26 !"))); + // A script whose text has no English either keeps it, reported as undrawn. + let greeting = [("hi", "Helv", "event.value = \"Привет \" + util.printd(\"mmm\", new Date(2026, 9, 18));")]; + let mut doc = form(&greeting); + fill(&mut doc, "hi"); + assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("hi".to_string(), 'П')]); + // The English run is the appearance's alone: scripts go on printing the date language. + assert_eq!(printd("dd-mmm-yy", "18-окт.-26"), "18-окт.-26|18-окт.-26"); + use_form_date_language("en"); +} + +/// A Format script's English run is kept when it ran, even where it prints the stored value +/// again: a Helvetica field storing "18-Oct-26", printed again as `dd-mmm-yy`, showed +/// "18-????.-26" in Russian. A script that fails keeps what it showed, reported undrawn (#5303). +#[test] +fn an_english_run_that_prints_the_stored_value_is_drawn() { + let _names = names_lock(); + let script = "event.value = util.printd(\"dd-mmm-yy\", util.scand(\"dd-mmm-yy\", event.value));"; + use_form_date_language("ru"); + let mut doc = form(&[("helv", "Helv", script), ("fails", "Helv", "throw new Error(\"no\");")]); + fill_with(&mut doc, "helv", "18-Oct-26"); + let helv = appearance(&doc, "helv"); + assert!(contains(&helv, b"(18-Oct-26) Tj"), "{}", String::from_utf8_lossy(&helv)); + assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv)); + assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty()); + fill_with(&mut doc, "fails", "18-окт.-26"); + assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("fails".to_string(), 'о')]); + use_form_date_language("en"); +} + +/// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`. +fn scanned(fmt: &str, text: &str) -> String { + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';"); + let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); + assert_eq!(o.error, None); + o.value +} + +/// A short weekday typed without its dot ("mar" for French "mar.", Tuesday) is the weekday where +/// the format has one, not the English "Mar" (March) that is the only other name of its length: +/// "mar 6 janv 2026" read as no date in French and Romanian (#5303). +#[test] +fn a_short_weekday_typed_without_its_dot_is_the_weekday() { + let _names = names_lock(); + let mut bad = Vec::new(); + let cases = [ + ("fr", "ddd d mmm yyyy", "Mar 6 janv 2026", "2026-01-06"), + ("fr", "ddd d mmm yyyy", "mar 17 mars 2026", "2026-03-17"), + ("fr", "mmm d yyyy ddd", "janv 6 2026 mar", "2026-01-06"), + ("fr", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"), + ("ro", "ddd d mmm yyyy", "mar 6 ian 2026", "2026-01-06"), + ("ro", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"), + ]; + for (language, fmt, text, want) in cases { + use_form_date_language(language); + let got = scanned(fmt, text); + if got != want { + bad.push(format!("{language} scand {fmt}: {text:?} -> {got}")); + } + let read = parse_date(text, fmt).map(|d| format!("{:04}-{:02}-{:02}", d.y, d.m, d.d)); + if read.as_deref() != Some(want) { + bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}")); + } + } + use_form_date_language("en"); + assert!(bad.is_empty(), "{}", bad.join("; ")); +} + +/// A weekday left out of a format with one ("17 mar 2026" for "ddd d mmm yyyy") lets a name the +/// date language shares between a weekday and a month be the month: Italian, Spanish and +/// Albanian "mar" (Tuesday, March) and Albanian "sht" (Saturday, September) were read as the +/// weekday, leaving no month (#5303). Typed weekdays still go first. +#[test] +fn a_shared_name_is_the_month_when_the_weekday_is_left_out() { + let _names = names_lock(); + let mut bad = Vec::new(); + let cases = [ + ("it", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)), + ("es", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)), + ("sq", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)), + ("it", "ddd d mmm yyyy", "17 mar 2026", (2026, 3, 17)), + ("it", "dddd, mmmm d, yyyy", "mar 17, 2026", (2026, 3, 17)), + ("it", "dddd, mmm d, yyyy", "mar 17 2026", (2026, 3, 17)), + ("sq", "ddd d mmm yyyy", "5 sht 2026", (2026, 9, 5)), + ("it", "ddd d mmm yyyy", "mar 17 Mar 2026", (2026, 3, 17)), + ("it", "ddd d mmm yyyy", "mar 17 mar 2026", (2026, 3, 17)), + ]; + for (language, fmt, text, want) in cases { + use_form_date_language(language); + let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d)); + if read != Some(want) { + bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}")); + } + if keystroke(&Format::Date(fmt.into()), "date", text).is_err() { + bad.push(format!("{language} keystroke {fmt}: {text:?} refused")); + } + } + use_form_date_language("en"); + assert!(bad.is_empty(), "{}", bad.join("; ")); +} + +/// Turkish names typed in capitals are read: lower-casing letter by letter made "MAYIS" "mayis" +/// (not "mayıs"), "SALI" "sali" and "EKİM" "eki̇m" (an i with a dot above), so no name matched. +/// Dotted, dotless and capital i are one letter when names are read (#5303). +#[test] +fn turkish_names_typed_in_capitals_are_read() { + let _names = names_lock(); + use_form_date_language("tr"); + let mut bad = Vec::new(); + for (fmt, text, want) in [ + ("d mmmm yyyy", "1 MAYIS 2026", (2026, 5, 1)), + ("d mmmm yyyy", "1 EKİM 2026", (2026, 10, 1)), + ("d mmmm yyyy", "1 Ekim 2026", (2026, 10, 1)), + ("d mmm yyyy", "1 EKİ 2026", (2026, 10, 1)), + ("dddd d mmmm yyyy", "SALI 6 OCAK 2026", (2026, 1, 6)), + ("dddd d mmmm yyyy", "Salı 6 Ocak 2026", (2026, 1, 6)), + ] { + let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d)); + if read != Some(want) { + bad.push(format!("forms {fmt}: {text:?} -> {read:?}")); + } + let (y, m, d) = want; + let got = scanned(fmt, text); + if got != format!("{y:04}-{m:02}-{d:02}") { + bad.push(format!("scand {fmt}: {text:?} -> {got}")); + } + } + // English is read as before, capitals and all. + use_form_date_language("en"); + for (fmt, text) in [("d mmmm yyyy", "1 APRIL 2026"), ("dddd d mmmm yyyy", "FRIDAY 1 MAY 2026"), ("d mmm yyyy", "1 MAY 2026")] { + if parse_date(text, fmt).is_none() || scanned(fmt, text) == "null" { + bad.push(format!("en {fmt}: {text:?}")); + } + } + assert!(bad.is_empty(), "{}", bad.join("; ")); +} + +/// #5464 review: Greek names typed in capitals end in a capital Σ, which is the final ς of the +/// name ("ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος"); they read as they did before names were folded. +#[test] +fn greek_names_typed_in_capitals_are_read() { + let _names = names_lock(); + use_form_date_language("el"); + let mut bad = Vec::new(); + for (fmt, text, want) in [ + ("d mmmm yyyy", "1 ΙΑΝΟΥΆΡΙΟΣ 2026", (2026, 1, 1)), + ("d mmmm yyyy", "1 Ιανουάριος 2026", (2026, 1, 1)), + ("d mmmm yyyy", "1 ιανουάριοσ 2026", (2026, 1, 1)), + ("mmmm d, yyyy", "ΜΆΙΟΣ 5, 2026", (2026, 5, 5)), + ("dddd, mmmm d, yyyy", "FRIDAY, ΙΑΝΟΥΆΡΙΟΣ 2, 2026", (2026, 1, 2)), + ] { + let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d)); + if read != Some(want) { + bad.push(format!("forms {fmt}: {text:?} -> {read:?}")); + } + let (y, m, d) = want; + let got = scanned(fmt, text); + if got != format!("{y:04}-{m:02}-{d:02}") { + bad.push(format!("scand {fmt}: {text:?} -> {got}")); + } + } + assert!(bad.is_empty(), "{}", bad.join("; ")); +} diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 86c7971c31..f5167374d1 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -20,7 +20,10 @@ mod script; #[cfg(test)] use calc::{Notation, eval_notation, notation_name, notation_tokens, unique}; pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation}; -pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate}; +pub use format::{ + DATE_PRESETS, DateNames, DateTime, ENGLISH_DATE_NAMES, TIME_PRESETS, date_names, format_date, format_date_in, format_value, format_value_in, + js_number, keystroke, make_number, parse_date, set_date_names, validate, +}; use script::{Arg, call}; pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js}; diff --git a/crates/forms/src/af/format.rs b/crates/forms/src/af/format.rs index df4720bec7..37d2163cb0 100644 --- a/crates/forms/src/af/format.rs +++ b/crates/forms/src/af/format.rs @@ -89,6 +89,165 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +/// The month and weekday names `mmmm`, `mmm`, `dddd` and `ddd` write (weekdays from Sunday), as +/// a localized Acrobat writes them in its own language (#5303). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DateNames { + pub months: [&'static str; 12], + pub months_short: [&'static str; 12], + pub days: [&'static str; 7], + pub days_short: [&'static str; 7], +} + +/// English, the names until the app sets others. +pub const ENGLISH_DATE_NAMES: DateNames = DateNames { + months: MONTHS, + months_short: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + days: DAYS, + days_short: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], +}; + +static DATE_NAMES: std::sync::RwLock = std::sync::RwLock::new(ENGLISH_DATE_NAMES); + +/// Sets the names date formats write from now on (the app's date language); typed dates are read +/// in them and in English. +pub fn set_date_names(names: DateNames) { + *DATE_NAMES.write().unwrap_or_else(std::sync::PoisonError::into_inner) = names; +} + +/// The names date formats write. +pub fn date_names() -> DateNames { + *DATE_NAMES.read().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// `text` with the month and weekday names of `lists` (in the order months, short months, +/// weekdays, short weekdays) written as their English names, so a parser that reads English +/// reads them. The longest name wins at each place, case ignored, and only a whole name: one +/// that doesn't continue a word, nor end in a digit that a number continues. A name may hold +/// digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs, which a +/// word-by-word reader would split (#5303). Where a weekday and a month share a name (Italian +/// "mar"), the order of the format's name fields (`mmm`, `ddd`) decides. English names are +/// found too, so that one typed ("Tue" before "Mar") fills its field of the format as a +/// localized one does. +fn english_names(text: &str, fmt: &str, lists: [&[&str]; 4]) -> String { + const EN: [&[&str]; 4] = [&MONTHS, &MONTHS, &DAYS, &DAYS]; + // No date is this long: hostile text is left as it is rather than scanned name by name. + if text.len() > MAX_DATE_TEXT_BYTES { + return text.to_string(); + } + let chars: Vec = text.chars().collect(); + let typed_english: [&[&str]; 4] = [&MONTHS, &ENGLISH_DATE_NAMES.months_short, &DAYS, &ENGLISH_DATE_NAMES.days_short]; + // Each name as folded chars, whether it is a month, and its English name. + let mut names: Vec<(Vec, bool, &str)> = Vec::new(); + for (k, (list, en)) in lists.into_iter().zip(EN).chain(typed_english.into_iter().zip(EN)).enumerate() { + for (name, english) in list.iter().zip(en.iter()) { + // A short name ending in a dot ("mar.") is also read typed without it, as the same + // kind of name, so that where it equals another ("Mar", March) the format decides. + let bare = name.trim_end_matches('.'); + for name in std::iter::once(*name).chain((bare.len() < name.len()).then_some(bare)) { + let n: Vec = name.chars().map(fold).collect(); + if !n.is_empty() && n.len() <= MAX_NAME_CHARS { + names.push((n, k % 4 < 2, english)); + } + } + } + } + names.sort_by_key(|(n, ..)| std::cmp::Reverse(n.len())); + // The format's name fields in order: true for a month, false for a weekday. + let f: Vec = fmt.chars().collect(); + let mut expected = Vec::new(); + let mut j = 0; + while let Some(&c) = f.get(j) { + let run = f.get(j..).map_or(1, |r| r.iter().take_while(|x| **x == c).count()).max(1); + if run >= 3 && (c == 'm' || c == 'd') { + expected.push(c == 'm'); + } + j = j.saturating_add(run); + } + let mut slot = 0; + // A name's first letter may not continue a word; its last may not run on into a letter, nor + // (a digit) into a number. + let letter = |c: Option<&char>| c.is_some_and(|c| c.is_alphabetic()); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while let Some(&c) = chars.get(i) { + let fits = |n: &Vec| { + let end = i.saturating_add(n.len()); + let first = n.first().and_then(|e| e.chars().next()); + let last = n.last().and_then(|e| e.chars().next()); + chars.get(i..end).is_some_and(|t| t.iter().map(|c| fold(*c)).eq(n.iter().cloned())) + && !(first.is_some_and(char::is_alphabetic) && letter(i.checked_sub(1).and_then(|p| chars.get(p)))) + && !match last { + Some(l) if l.is_ascii_digit() => chars.get(end).is_some_and(char::is_ascii_digit), + Some(l) if l.is_alphabetic() => letter(chars.get(end)), + _ => false, + } + }; + let longest = names.iter().find(|(n, ..)| fits(n)).map(|(n, ..)| n.len()); + let hit = longest.and_then(|len| { + let mut same = names.iter().filter(|(n, ..)| n.len() == len && fits(n)); + let want = expected.get(slot).copied(); + let all: Vec<_> = same.by_ref().collect(); + all.iter().find(|(_, month, _)| Some(*month) == want).or_else(|| all.first()).copied() + }); + match hit { + Some((n, month, english)) => { + if expected.get(slot) == Some(month) { + slot += 1; + } + // Apart from what touches it ("10月18"), but no space added at either end. + if out.ends_with(char::is_alphanumeric) { + out.push(' '); + } + out.push_str(english); + i = i.saturating_add(n.len()); + if chars.get(i).is_some_and(|c| c.is_alphanumeric()) { + out.push(' '); + } + } + None => { + out.push(c); + i += 1; + } + } + } + out +} + +/// `c` as names are compared: lower case, with the capital I and the Turkish dotted and dotless +/// i all "i", and a combining dot above (U+0307) dropped, so that "EKİM", "SALI" and "MAYIS" are +/// Turkish "Ekim", "Salı" and "Mayıs", which lower-casing alone makes "ekim" with a dotted i, +/// "sali" and "mayis" (#5303). The Greek final sigma is a sigma, since a capital Σ, which +/// lower-cases to σ on its own, ends "ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος". One piece for each char, so +/// positions in the text stay aligned. +fn fold(c: char) -> String { + match c { + 'I' | 'ı' | 'İ' => "i".to_string(), + 'ς' => "σ".to_string(), + _ => c.to_lowercase().filter(|&l| l != '\u{307}').collect(), + } +} + +/// `s` [`fold`]ed char by char. +fn folded(s: &str) -> String { + s.chars().map(fold).collect() +} + +/// Typed dates longer than this are not searched for names. +const MAX_DATE_TEXT_BYTES: usize = 1024; + +/// Names longer than this are not looked for (settings are trusted, but a cap keeps the scan cheap). +const MAX_NAME_CHARS: usize = 32; + +/// The month (1 to 12) a typed word names: a start of at least three letters of a month's name, +/// or its whole short name, in `names` or in English. +fn month_named(word: &str, names: &DateNames) -> Option { + let w = folded(word); + let starts = |list: &[&str; 12]| list.iter().position(|m| w.chars().count() >= 3 && folded(m).starts_with(&w)); + let whole = |list: &[&str; 12]| list.iter().position(|m| folded(m).trim_end_matches('.') == w); + starts(&names.months).or_else(|| whole(&names.months_short)).or_else(|| starts(&MONTHS)).map(|i| i as u32 + 1) +} + /// A date and time read from text. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct DateTime { @@ -202,7 +361,20 @@ fn order(fmt: &str) -> Vec { /// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any /// separators, month names, two-digit years). `None` when it isn't a valid date. pub fn parse_date(text: &str, fmt: &str) -> Option { - let t = text.trim(); + let names = date_names(); + let lists: [&[&str]; 4] = [&names.months, &names.months_short, &names.days, &names.days_short]; + // Weekdays are shown, not typed (see `order`): where the format has one but none was typed, a + // name the language shares between a weekday and a month (Italian "mar") is the month, as + // the names are read again with no weekday expected (#5303). + read_date(&english_names(text, fmt, lists), fmt, &names).or_else(|| { + let no_weekday = fmt.replace("dddd", " ").replace("ddd", " "); + (no_weekday != fmt).then(|| read_date(&english_names(text, &no_weekday, lists), fmt, &names)).flatten() + }) +} + +/// [`parse_date`] of `english`, typed text whose month and weekday names are English. +fn read_date(english: &str, fmt: &str, names: &DateNames) -> Option { + let t = english.trim(); if t.is_empty() { return None; } @@ -223,10 +395,8 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { } if cur.starts_with(|c: char| c.is_ascii_digit()) { nums.push_back(std::mem::take(cur)); - } else if cur.len() >= 3 - && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) - { - *month = Some(i as u32 + 1); + } else if let Some(m) = month_named(cur, names) { + *month = Some(m); } cur.clear(); }; @@ -337,6 +507,11 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { /// Format a date/time with Acrobat's date format letters. pub fn format_date(dt: DateTime, fmt: &str) -> String { + format_date_in(dt, fmt, &date_names()) +} + +/// [`format_date`] with month and weekday names from `names`. +pub fn format_date_in(dt: DateTime, fmt: &str, names: &DateNames) -> String { let chars: Vec = fmt.chars().collect(); let mut out = String::new(); let mut i = 0; @@ -354,9 +529,9 @@ pub fn format_date(dt: DateTime, fmt: &str) -> String { ('y', _) => { let _ = write!(out, "{:02}", dt.y.rem_euclid(100)); } - ('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) { - Some(name) if n >= 4 => out.push_str(name), - Some(name) => out.push_str(name.get(..3).unwrap_or(name)), + ('m', 3..) => match (dt.m as usize).checked_sub(1).and_then(|i| names.months.get(i).zip(names.months_short.get(i))) { + Some((name, _)) if n >= 4 => out.push_str(name), + Some((_, short)) => out.push_str(short), None => { let _ = write!(out, "{}", dt.m); } @@ -368,8 +543,8 @@ pub fn format_date(dt: DateTime, fmt: &str) -> String { let _ = write!(out, "{}", dt.m); } ('d', 3..) => { - let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default(); - out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) }); + let list = if n >= 4 { &names.days } else { &names.days_short }; + out.push_str(list.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default()); } ('d', 2) => { let _ = write!(out, "{:02}", dt.d); @@ -451,6 +626,11 @@ fn apply_mask(mask: &str, text: &str) -> Option { /// The text shown for a value (the Format event). pub fn format_value(f: &Format, value: &str) -> String { + format_value_in(f, value, &date_names()) +} + +/// [`format_value`] with month and weekday names from `names`. +pub fn format_value_in(f: &Format, value: &str, names: &DateNames) -> String { if value.is_empty() { return String::new(); } @@ -479,7 +659,7 @@ pub fn format_value(f: &Format, value: &str) -> String { ), None => value.to_string(), }, - Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)), + Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date_in(dt, fmt, names)), Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()), Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()), } diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index c9ff86d17a..8403b729d9 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -739,8 +739,18 @@ pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option field_appearance_fit(doc, f, w, &f.value, true).2 } +/// Whether `text` draws in widget `wd` of `f` with no `?`: in the field's `/DR` font when that +/// holds it, as [`field_appearance_fit`] picks, or else in the Helvetica (WinAnsi) stand-in. +fn draws(doc: &Document, f: &Field, wd: &Dict, text: &str) -> bool { + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + let enc = dr_font(doc, &da.font).map(|(_, enc)| enc).filter(|enc| enc.can_encode(text)).unwrap_or(FontText::WinAnsi); + enc.first_unencodable(text).is_none() +} + /// [`field_appearance_drawn`], and whether the text drawn is cut off by the box. fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option, Option) { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); // The Format event: what is shown, not what is stored. let formatted: Vec; let values = if format @@ -748,13 +758,16 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] && values.len() == 1 && f.actions.format != crate::af::Format::None { - formatted = vec![crate::af::format_value(&f.actions.format, &values[0])]; + let shown = crate::af::format_value(&f.actions.format, &values[0]); + // Month and weekday names in the date language that no font here can draw ("окт." in + // Helvetica) are drawn in English rather than as `?` (#5303). + let english = || crate::af::format_value_in(&f.actions.format, &values[0], &crate::af::ENGLISH_DATE_NAMES); + let dated = matches!(f.actions.format, crate::af::Format::Date(_) | crate::af::Format::Time(_)); + formatted = vec![if !dated || draws(doc, f, &wd, &shown) { shown } else { english() }]; &formatted[..] } else { values }; - let wobj = doc.get(w.obj); - let wd = wobj.as_dict().cloned().unwrap_or_default(); let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); let place = placement(mk_rotation(doc, &wd), page_w, page_h); let (width, height) = (place.layout_w, place.layout_h); diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 7dd7bfde6c..31e6812e6b 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -60,6 +60,21 @@ pub struct ScriptResult { pub trait Scripts { /// Run `script` for `event` on `target`, whose value is `value`; `fields` is the whole form. fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult; + + /// [`Self::run`] again for an appearance, with dates written in English names (`util.printd`) + /// because no font of the field can draw the date language's (#5303); what it logs was + /// already logged by the first run. `None` when it can't: no English dates here, or the + /// script failed. What it returns is what the script made, even when that is the value. + fn rerun_with_english_dates( + &mut self, + _event: FieldEvent, + _script: &str, + _target: &Field, + _value: &str, + _fields: &[Field], + ) -> Option { + None + } } /// No script engine (or JavaScript turned off): scripts change nothing. @@ -98,6 +113,13 @@ pub(crate) fn formatted(doc: &Document, f: &Field, value: &str, scripts: &mut dy Some(r.value) } +/// [`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 { + let js = f.actions.scripts.format.as_ref()?; + scripts.rerun_with_english_dates(FieldEvent::Format, js, f, value, &fields(doc)).map(|r| r.value) +} + /// Apply script changes to other fields (values written without re-running their scripts; /// read-only, required and visibility as flags). `except` is the event's own field, whose value /// the event sets. diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index cf191c2240..9d670e62db 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -426,9 +426,24 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc }; let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD); let mut undrawn = false; + // The Format script again with English month and weekday names, once, for widgets whose + // fonts can't draw the date language's (#5303). + let mut english: Option> = None; for w in &f.widgets { let (stream, missing) = match &shown { - Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false), + Some(s) => { + let drawn = appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false); + // Taken when the English run happened and draws whole, even where it is the + // stored value ("18-Oct-26" printed again from itself). + let again = match (&drawn.1, values) { + (Some(_), [one]) => english.get_or_insert_with(|| scripting::formatted_in_english(doc, f, one, scripts)).as_ref(), + _ => None, + }; + match again.map(|e| appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(e), false)) { + Some(english) if english.1.is_none() => english, + _ => drawn, + } + } None => appearance::field_appearance_drawn(doc, f, w, values, true), }; undrawn |= missing.is_some(); diff --git a/crates/js/src/format.rs b/crates/js/src/format.rs index 7f207b1916..26e2fad989 100644 --- a/crates/js/src/format.rs +++ b/crates/js/src/format.rs @@ -134,6 +134,177 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +/// Month and weekday names (weekdays from Sunday): full, then short. +type Names = ([&'static str; 12], [&'static str; 12], [&'static str; 7], [&'static str; 7]); + +const ENGLISH: Names = ( + MONTHS, + ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + DAYS, + ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], +); + +static NAMES: std::sync::RwLock = std::sync::RwLock::new(ENGLISH); + +/// Sets the month and weekday names `util.printd` writes for `mmmm`, `mmm`, `dddd` and `ddd` +/// (weekdays from Sunday), as a localized Acrobat writes its own language's (#5303). +/// `util.scand` reads them as well as English. +pub fn set_date_names(months: [&'static str; 12], months_short: [&'static str; 12], days: [&'static str; 7], days_short: [&'static str; 7]) { + *NAMES.write().unwrap_or_else(std::sync::PoisonError::into_inner) = (months, months_short, days, days_short); +} + +/// `text` with the month and weekday names of `lists` (in the order months, short months, +/// weekdays, short weekdays) written as their English names, so a parser that reads English +/// reads them. The longest name wins at each place, case ignored, and only a whole name: one +/// that doesn't continue a word, nor end in a digit that a number continues. A name may hold +/// digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs, which a +/// word-by-word reader would split (#5303). Where a weekday and a month share a name (Italian +/// "mar"), the order of the format's name fields (`mmm`, `ddd`) decides. English names are +/// found too, so that one typed ("Tue" before "Mar") fills its field of the format as a +/// localized one does. +fn english_names(text: &str, fmt: &str, lists: [&[&str]; 4]) -> String { + const EN: [&[&str]; 4] = [&MONTHS, &MONTHS, &DAYS, &DAYS]; + // No date is this long: hostile text is left as it is rather than scanned name by name. + if text.len() > MAX_DATE_TEXT_BYTES { + return text.to_string(); + } + let chars: Vec = text.chars().collect(); + let typed_english: [&[&str]; 4] = [&ENGLISH.0, &ENGLISH.1, &ENGLISH.2, &ENGLISH.3]; + // Each name as folded chars, whether it is a month, and its English name. + let mut names: Vec<(Vec, bool, &str)> = Vec::new(); + for (k, (list, en)) in lists.into_iter().zip(EN).chain(typed_english.into_iter().zip(EN)).enumerate() { + for (name, english) in list.iter().zip(en.iter()) { + // A short name ending in a dot ("mar.") is also read typed without it, as the same + // kind of name, so that where it equals another ("Mar", March) the format decides. + let bare = name.trim_end_matches('.'); + for name in std::iter::once(*name).chain((bare.len() < name.len()).then_some(bare)) { + let n: Vec = name.chars().map(fold).collect(); + if !n.is_empty() && n.len() <= MAX_NAME_CHARS { + names.push((n, k % 4 < 2, english)); + } + } + } + } + names.sort_by_key(|(n, ..)| std::cmp::Reverse(n.len())); + // The format's name fields in order: true for a month, false for a weekday. + let f: Vec = fmt.chars().collect(); + let mut expected = Vec::new(); + let mut j = 0; + while let Some(&c) = f.get(j) { + let run = f.get(j..).map_or(1, |r| r.iter().take_while(|x| **x == c).count()).max(1); + if run >= 3 && (c == 'm' || c == 'd') { + expected.push(c == 'm'); + } + j = j.saturating_add(run); + } + let mut slot = 0; + // A name's first letter may not continue a word; its last may not run on into a letter, nor + // (a digit) into a number. + let letter = |c: Option<&char>| c.is_some_and(|c| c.is_alphabetic()); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while let Some(&c) = chars.get(i) { + let fits = |n: &Vec| { + let end = i.saturating_add(n.len()); + let first = n.first().and_then(|e| e.chars().next()); + let last = n.last().and_then(|e| e.chars().next()); + chars.get(i..end).is_some_and(|t| t.iter().map(|c| fold(*c)).eq(n.iter().cloned())) + && !(first.is_some_and(char::is_alphabetic) && letter(i.checked_sub(1).and_then(|p| chars.get(p)))) + && !match last { + Some(l) if l.is_ascii_digit() => chars.get(end).is_some_and(char::is_ascii_digit), + Some(l) if l.is_alphabetic() => letter(chars.get(end)), + _ => false, + } + }; + let longest = names.iter().find(|(n, ..)| fits(n)).map(|(n, ..)| n.len()); + let hit = longest.and_then(|len| { + let mut same = names.iter().filter(|(n, ..)| n.len() == len && fits(n)); + let want = expected.get(slot).copied(); + let all: Vec<_> = same.by_ref().collect(); + all.iter().find(|(_, month, _)| Some(*month) == want).or_else(|| all.first()).copied() + }); + match hit { + Some((n, month, english)) => { + if expected.get(slot) == Some(month) { + slot += 1; + } + // Apart from what touches it ("10月18"), but no space added at either end. + if out.ends_with(char::is_alphanumeric) { + out.push(' '); + } + out.push_str(english); + i = i.saturating_add(n.len()); + if chars.get(i).is_some_and(|c| c.is_alphanumeric()) { + out.push(' '); + } + } + None => { + out.push(c); + i += 1; + } + } + } + out +} + +/// `c` as names are compared: lower case, with the capital I and the Turkish dotted and dotless +/// i all "i", and a combining dot above (U+0307) dropped, so that "EKİM", "SALI" and "MAYIS" are +/// Turkish "Ekim", "Salı" and "Mayıs", which lower-casing alone makes "ekim" with a dotted i, +/// "sali" and "mayis" (#5303). The Greek final sigma is a sigma, since a capital Σ, which +/// lower-cases to σ on its own, ends "ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος". One piece for each char, so +/// positions in the text stay aligned. +fn fold(c: char) -> String { + match c { + 'I' | 'ı' | 'İ' => "i".to_string(), + 'ς' => "σ".to_string(), + _ => c.to_lowercase().filter(|&l| l != '\u{307}').collect(), + } +} + +/// `s` [`fold`]ed char by char. +fn folded(s: &str) -> String { + s.chars().map(fold).collect() +} + +/// Typed dates longer than this are not searched for names. +const MAX_DATE_TEXT_BYTES: usize = 1024; + +/// Names longer than this are not looked for (settings are trusted, but a cap keeps the scan cheap). +const MAX_NAME_CHARS: usize = 32; + +fn names() -> Names { + *NAMES.read().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +std::thread_local! { + /// Whether `util.printd` on this thread writes English names, whatever the date language. + static PRINTD_ENGLISH: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Runs `run` with `util.printd` writing English month and weekday names when `english` (scripts +/// it starts carry this to their own thread): for an appearance whose fonts can't draw the date +/// language's names, which would come out as `?` (#5303). `util.scand` still reads both. +pub fn with_english_printd(english: bool, run: impl FnOnce() -> R) -> R { + struct Restore(bool); + impl Drop for Restore { + fn drop(&mut self) { + PRINTD_ENGLISH.with(|e| e.set(self.0)); + } + } + let _restore = Restore(PRINTD_ENGLISH.with(|e| e.replace(english))); + run() +} + +/// Whether [`with_english_printd`] is on for this thread. +pub(crate) fn printd_english() -> bool { + PRINTD_ENGLISH.with(std::cell::Cell::get) +} + +/// The names `util.printd` writes. +fn printd_names() -> Names { + if printd_english() { ENGLISH } else { names() } +} + /// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt, /// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt). pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { @@ -161,6 +332,7 @@ pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResu let (y, mo, day, wd, h, mi, sec) = (part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?); let mut out = String::new(); + let (months, months_short, days, days_short) = printd_names(); let b = fmt.as_bytes(); let mut i = 0; while i < b.len() { @@ -186,13 +358,13 @@ pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResu } } b'm' => match run { - r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]), - 3 => out.push_str(&MONTHS[mo as usize % 12][..3]), + r if r >= 4 => out.push_str(months.get(mo.rem_euclid(12) as usize).copied().unwrap_or_default()), + 3 => out.push_str(months_short.get(mo.rem_euclid(12) as usize).copied().unwrap_or_default()), n => push_num(&mut out, mo + 1, n), }, b'd' => match run { - r if r >= 4 => out.push_str(DAYS[wd as usize % 7]), - 3 => out.push_str(&DAYS[wd as usize % 7][..3]), + r if r >= 4 => out.push_str(days.get(wd.rem_euclid(7) as usize).copied().unwrap_or_default()), + 3 => out.push_str(days_short.get(wd.rem_euclid(7) as usize).copied().unwrap_or_default()), n => push_num(&mut out, day, n), }, b'H' => push_num(&mut out, h, run), @@ -435,11 +607,13 @@ fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { p += 1; } (ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => { - v.mo = Some(named(&MONTHS, l)?.saturating_add(1)); + let (months, months_short, ..) = names(); + v.mo = Some(named(&months, l).or_else(|| named(&months_short, l)).or_else(|| named(&MONTHS, l))?.saturating_add(1)); p += 1; } (ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => { - named(&DAYS, l)?; + let (.., days, days_short) = names(); + named(&days, l).or_else(|| named(&days_short, l)).or_else(|| named(&DAYS, l))?; p += 1; } (ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => { @@ -509,10 +683,11 @@ fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { /// The index of the name `word` is, in full or as its first three letters, in any case. fn named(names: &[&str], word: &str) -> Option { - let word = word.to_lowercase(); + let word = folded(word); let i = names.iter().position(|n| { - let n = n.to_lowercase(); - word == n || n.get(..3) == Some(word.as_str()) + let n = folded(n); + // A short name may end in a dot ("Okt."); three letters may be cut from any name. + word == n || word == n.trim_end_matches('.') || n.chars().take(3).collect::() == word })?; u32::try_from(i).ok() } @@ -524,6 +699,8 @@ pub(crate) fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResul return Ok(JsValue::null()); }; let fmt = text(&arg(args, 0), ctx)?; + let (months, months_short, days, days_short) = names(); + let typed = english_names(&typed, &fmt, [&months, &months_short, &days, &days_short]); let date = JsDate::new(ctx); let this_year = date.get_full_year(ctx)?.to_number(ctx)?; let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 }; diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 93f57e7528..0bf4032efd 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -44,6 +44,7 @@ mod run; #[cfg(test)] pub(crate) use format::PRINTD_SCANNED; use format::{printd, printf, printx, scand}; +pub use format::{set_date_names, with_english_printd}; pub(crate) use globals::menu_item; use globals::platform; use host::{arg, as_number, error, function, s, text}; diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index 07c0e984bb..ab7e32ed53 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -129,11 +129,13 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), Err(e) => failed(format!("the script engine could not start: {e}")), diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 59026c26aa..fd8d4a8096 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -452,6 +452,9 @@ impl eframe::App for PdfCraftApp { // Notices raised outside `ui` (opened files, OS events, the control channel) translate too. let lang = i18n::Lang::from_pref(&self.language); i18n::set_current(lang); + // Form date fields name months in the date language (Preferences ▸ Date format), which + // follows the interface language until one is picked (#5303). + pdfcraft_engine::dates::use_form_date_language(self.session.date_language().unwrap_or(lang.code())); #[cfg(not(target_arch = "wasm32"))] { if let Some(worker) = &self.open_worker { From 42bc4f117d849b58cf01f5c65f0aadfa1d4f24cf Mon Sep 17 00:00:00 2001 From: ohagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:41:45 +0100 Subject: [PATCH 11/43] M5: copy and paste comments between documents, select several, finish connected lines from the menu (#3484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments copy and paste, within and between documents. ⌘C with comments selected (and no text selected) copies them, each with its replies, as XFDF: into the app's clipboard (every tab and window) and as text onto the system clipboard. ⌘V, Edit ▸ Paste comments or the page's context menu pastes them onto the current page at their own positions, as new comments under names of their own, so nothing is replaced and pasting twice gives two sets; the copies are selected. XFDF with comments from the system clipboard pastes too, so comments travel between processes. pdfcraft_xfdf::export_xfdf_comments writes a selection with its replies; import_comments takes a target page and a name prefix and imports copies (the usual import still replaces same names). Engine: Edit::PasteComments { xfdf, page } (one undo step, comment permission) and Session::export_comments. Tools: comments_copy (read-only) and comments_paste. Several comments select at once: ⌘-click (Ctrl on Windows and Linux) or ⇧-click adds one or takes it out, on the page and in the Comments panel; the one clicked last has the handles. Delete removes them all as one step, a drag moves them together, the context menu copies them. Connected lines, polygons and clouds finish from the right-click menu's Complete (with Undo last point and Cancel drawing), as other editors do, besides Enter and a double-click. Co-authored-by: Claude Fable 5.1 Co-authored-by: FredApps --- ROADMAP.md | 2 + crates/annot/src/appearance.rs | 45 +- crates/annot/src/types.rs | 22 + crates/automation/src/comments.rs | 43 ++ crates/automation/src/dispatch.rs | 2 + crates/automation/src/tools.rs | 19 + crates/automation/tests/it/automation.rs | 46 ++ crates/engine/src/commands.rs | 4 + crates/engine/src/edit.rs | 9 + crates/engine/src/edit/labels.rs | 2 + crates/engine/src/edit/run.rs | 9 + crates/engine/src/history.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/session/files.rs | 13 + crates/engine/src/tests.rs | 36 ++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/clipboard.rs | 96 +++- crates/ui-egui/src/app/control.rs | 2 +- crates/ui-egui/src/canvas.rs | 89 ++- crates/ui-egui/src/canvas/page_input.rs | 7 +- crates/ui-egui/src/commands.rs | 11 + crates/ui-egui/src/control/host.rs | 2 + crates/ui-egui/src/dialogs/comment_props.rs | 125 ++++- crates/ui-egui/src/editing.rs | 31 +- crates/ui-egui/src/hotbar.rs | 15 +- crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/src/menus.rs | 3 + crates/ui-egui/src/panels/comments_panel.rs | 35 +- crates/ui-egui/src/state.rs | 8 + crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 185 +++++-- crates/ui-egui/src/tools/comments/menu.rs | 85 ++- crates/ui-egui/src/tools/comments/paint.rs | 89 ++- crates/ui-egui/src/tools/comments/state.rs | 71 ++- crates/ui-egui/src/tools/comments/tests.rs | 28 +- crates/ui-egui/src/tools/fill_sign.rs | 2 +- crates/ui-egui/src/tools/measure_ui.rs | 2 +- crates/ui-egui/tests/it/commands.rs | 16 + crates/ui-egui/tests/it/comments.rs | 452 ++++++++++++++++ crates/xfdf/src/lib.rs | 2 +- crates/xfdf/src/tests.rs | 321 +++++++++++ crates/xfdf/src/xfdf.rs | 568 +++++++++++++++++--- docs/ui-parity.md | 1 + parity/acrobat-features.toml | 34 +- 44 files changed, 2317 insertions(+), 227 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cf8cacb73e..3ce7c13542 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,6 +349,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged. - **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged. +- **2026-10-20 (M5, several comments):** Comment Properties opened on one of several selected comments, the context menu's Colour and the quick bar's colour change all of them as one undo step, each where the property applies (a font size to text boxes, a fill to shapes, a width to lines and shapes); one that stays locked keeps its look. Copied Fill & Sign ticks, crosses, dots and lines hang on the pointer as themselves rather than as empty boxes, and copied text boxes show their text (`FillMark::strokes` is shared by the appearance and the outline). Estimates unchanged. +- **2026-10-19 (M5, comments between documents):** Comments copy and paste, within and between documents: ⌘C with comments selected copies them as XFDF (each with its replies) to the app's and the system clipboard, ⌘V / Edit ▸ Paste comments / the page menu pastes them onto the current page as new comments under names of their own (`Edit::PasteComments`, `pdfcraft_xfdf::import_comments` with a target page and fresh names, so nothing is replaced and pasting twice gives two sets; `export_xfdf_comments` writes a selection with its replies). Several comments select at once (⌘-click, ⇧-click, on the page and in the panel); Delete removes them as one step and a drag moves them together. Connected lines, polygons and clouds finish from the right-click menu's Complete (Undo last point, Cancel drawing beside it), as other editors do. Tools `comments_copy` / `comments_paste`. Estimates unchanged. - **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index dbecae5c6d..c2f7f5e53d 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -876,46 +876,25 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - let [x0, y0, x1, y1] = draw; let (w, h) = (x1 - x0, y1 - y0); let lw = w.min(h) * 0.12; + // The strokes come from `FillMark::strokes`, which a copy's outline draws too. + let path = |mark: crate::FillMark| { + let ops: Vec = mark + .strokes(draw) + .iter() + .flat_map(|s| s.iter().enumerate().map(|(i, p)| format!("{} {} {}", n(p[0]), n(p[1]), if i == 0 { "m" } else { "l" }))) + .collect(); + format!("{} S\n", ops.join(" ")) + }; match name { - b"PCCheck" => c.push_str(&format!( - "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.15), - n(y0 + h * 0.5), - n(x0 + w * 0.4), - n(y0 + h * 0.2), - n(x0 + w * 0.88), - n(y0 + h * 0.85) - )), - b"PCCross" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.18), - n(y0 + h * 0.18), - n(x1 - w * 0.18), - n(y1 - h * 0.18), - n(x0 + w * 0.18), - n(y1 - h * 0.18), - n(x1 - w * 0.18), - n(y0 + h * 0.18) - )), + b"PCCheck" => c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(lw), path(crate::FillMark::Check))), + b"PCCross" => c.push_str(&format!("{}{} w 1 J\n{}", rg_stroke(col), n(lw), path(crate::FillMark::Cross))), b"PCDot" => { let r = w.min(h) * 0.3; c.push_str(&rg(col)); c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); c.push_str("f\n"); } - b"PCLine" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l S\n", - rg_stroke(col), - n(h.clamp(0.5, 2.0)), - n(x0), - n(y0 + h / 2.0), - n(x1), - n(y0 + h / 2.0) - )), + b"PCLine" => c.push_str(&format!("{}{} w 1 J\n{}", rg_stroke(col), n(h.clamp(0.5, 2.0)), path(crate::FillMark::Line))), other => { // Only stamps PdfCraft made: others keep their own artwork. if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 2831613091..935eff8759 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -124,6 +124,28 @@ impl FillMark { let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) }; [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0] } + + /// The mark whose `/Name` this is. + pub fn from_name(n: &[u8]) -> Option { + [FillMark::Check, FillMark::Cross, FillMark::Dot, FillMark::Line].into_iter().find(|m| m.name().as_bytes() == n) + } + + /// The pen strokes the mark is drawn with in `rect` (user space): what its appearance strokes, + /// and what a copy of it shows while it is being placed. The dot is a filled circle (radius + /// 0.3 × the shorter side, centred) and has none. + pub fn strokes(self, rect: [f64; 4]) -> Vec> { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + match self { + FillMark::Check => vec![vec![[x0 + w * 0.15, y0 + h * 0.5], [x0 + w * 0.4, y0 + h * 0.2], [x0 + w * 0.88, y0 + h * 0.85]]], + FillMark::Cross => vec![ + vec![[x0 + w * 0.18, y0 + h * 0.18], [x1 - w * 0.18, y1 - h * 0.18]], + vec![[x0 + w * 0.18, y1 - h * 0.18], [x1 - w * 0.18, y0 + h * 0.18]], + ], + FillMark::Dot => Vec::new(), + FillMark::Line => vec![vec![[x0, y0 + h / 2.0], [x1, y0 + h / 2.0]]], + } + } } /// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style). diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 2ca9fbd540..6adc95b2ab 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -746,6 +746,49 @@ impl Automation { let (page, index) = self.comment_target(a)?; self.apply(a, Edit::DeleteAnnotation { page, index }) } + + /// The XFDF of the listed comments and their replies (`comments`: 1-based [page, index] pairs). + pub(crate) fn comments_copy(&self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let all = self.comments(a)?; + let shape = || ToolError::InvalidArgs("comments: [[page, index], …] (1-based, as comment_list reports)".into()); + let list = a.get("comments").and_then(Value::as_array).ok_or_else(shape)?; + let mut which = Vec::with_capacity(list.len()); + for item in list { + let pair = item.as_array().filter(|p| p.len() == 2).and_then(|p| Some((p.first()?.as_u64()?, p.get(1)?.as_u64()?))); + let Some((page, index)) = pair.filter(|(p, i)| *p >= 1 && *i >= 1) else { return Err(shape()) }; + let (page, index) = (usize::try_from(page - 1).map_err(|_| shape())?, usize::try_from(index - 1).map_err(|_| shape())?); + if !all.iter().any(|c| c.page == page && c.index == index) { + return Err(failed(format!("there is no comment {} on page {} (see comment_list)", index + 1, page + 1))); + } + which.push((page, index)); + } + let xfdf = self.session.export_comments(id, &which).map_err(failed)?; + Ok(json!({ "xfdf": xfdf, "comments": which.len() })) + } + + /// Add the comments of `xfdf` (from `comments_copy`) to `page` as new comments. + pub(crate) fn comments_paste(&mut self, a: &Args) -> Result { + let page = self.page(a)?; + let xfdf = a.str("xfdf")?.to_owned(); + if !pdfcraft_engine::is_xfdf_comments(&xfdf) { + return Err(ToolError::InvalidArgs("xfdf: the text comments_copy returned (XFDF with )".into())); + } + let offset = match a.nums::<2>("at")? { + Some([x, y]) => { + let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| failed(format!("no page {}", page + 1)))?; + let [cx, cy] = to_user(&info, x, y); + let b = + pdfcraft_engine::comments_bounds(&xfdf).ok_or_else(|| ToolError::InvalidArgs("xfdf: no comment with a rect to place".into()))?; + Some([cx - (b[0] + b[2]) / 2.0, cy - (b[1] + b[3]) / 2.0]) + } + None => None, + }; + let before = self.comments(a)?.len(); + let mut out = self.apply(a, Edit::PasteComments { xfdf, page, offset })?; + out["comments"] = json!(self.comments(a)?.len().saturating_sub(before)); + Ok(out) + } } impl Automation { diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 45df846ebc..71a979b637 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -867,6 +867,8 @@ impl Automation { "comments_summarize" => self.comments_summarize(&a)?, "comment_edit" => self.comment_edit(&a)?, "comment_delete" => self.comment_delete(&a)?, + "comments_copy" => self.comments_copy(&a)?, + "comments_paste" => self.comments_paste(&a)?, other => return Err(ToolError::UnknownTool(other.into())), }; Ok(vec![Content::Json(out)]) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 7b55a80094..b3b149fbc2 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1013,6 +1013,25 @@ pub fn tools() -> Vec { t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") .ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])), t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), + t( + "comments_copy", + "Copy comments", + "The XFDF of these comments, each with its replies: what Copy puts on the clipboard. Paste it into this or another document with comments_paste. `comments`: [[page, index], …], 1-based as comment_list reports them.", + ) + .ro() + .with(schema( + json!({ + "doc": doc(), + "comments": { "type": "array", "items": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "minItems": 2, "maxItems": 2 }, "minItems": 1 } + }), + &["doc", "comments"], + )), + t( + "comments_paste", + "Paste comments", + "Add the comments of an XFDF text (from comments_copy) to `page` as new comments: copies under names of their own, so nothing in the document is replaced and pasting twice gives two sets; replies keep their copied parent. `at` [x, y] (points, origin at the top-left of the displayed page, y down, as comment_add takes them) puts the centre of the copies' bounding box there; without it they keep their positions. Undoable.", + ) + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "xfdf": { "type": "string" }, "at": point() }), &["doc", "page", "xfdf"])), t( "doc_protect", "Protect with passwords", diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 42a88a3633..f0426fa3fc 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10975,3 +10975,49 @@ fn a_bad_bookmark_tree_is_refused_whole_and_names_the_node() { assert!(!out.exists()); assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([])); } + +/// Copy and paste comments between documents through the tools: the copies carry their replies, +/// land on the asked-for page under their own names (a second paste gives a second set), and a +/// plain text is refused. +#[test] +fn comments_copy_and_paste_between_documents_through_tools() { + let dir = workdir("comments-clipboard"); + let mut a = auto(&dir); + let src = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": src, "page": 2, "type": "note", "at": [30, 30], "contents": "Please review", "author": "Ada" })); + ok(&mut a, "comment_add", json!({ "doc": src, "page": 2, "type": "rectangle", "rect": [20, 20, 80, 60], "contents": "Box" })); + ok(&mut a, "comment_reply", json!({ "doc": src, "page": 2, "index": 1, "text": "Done", "author": "Bob" })); + // Positions as comment_list reports them (a note's pop-up takes a position of its own). + let listed = ok(&mut a, "comment_list", json!({ "doc": src, "page": 2 })); + let pairs: Vec = listed["comments"].as_array().unwrap().iter().map(|c| json!([c["page"], c["index"]])).collect(); + assert_eq!(pairs.len(), 2, "{listed}"); + let copied = ok(&mut a, "comments_copy", json!({ "doc": src, "comments": pairs })); + assert_eq!(copied["comments"], 2); + let xfdf = copied["xfdf"].as_str().unwrap().to_owned(); + assert!(xfdf.contains("inreplyto=") && xfdf.contains("> = + placed["comments"].as_array().unwrap().iter().map(|c| c["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()).collect(); + let (x0, y0, x1, y1) = + rects.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |(a, b, c, d), r| (a.min(r[0]), b.min(r[1]), c.max(r[2]), d.max(r[3]))); + assert!(((x0 + x1) / 2.0 - 100.0).abs() < 1.0 && ((y0 + y1) / 2.0 - 150.0).abs() < 1.0, "centred at [100, 150]: {rects:?}"); + assert!(matches!(a.call("comments_paste", &json!({ "doc": dst, "page": 1, "xfdf": "hello" })), Err(ToolError::InvalidArgs(_)))); + assert!(a.call("comments_copy", &json!({ "doc": src, "comments": [[9, 9]] })).is_err(), "no such comment"); + assert!(matches!(a.call("comments_copy", &json!({ "doc": src, "comments": [[2]] })), Err(ToolError::InvalidArgs(_)))); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": src }))["comments"].as_array().unwrap().len(), 2, "the source is untouched"); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index bd229656a6..9301201661 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -220,6 +220,10 @@ pub const COMMANDS: &[CommandSpec] = &[ // Alt-drag for moving windows (several Linux window managers). // Copy events stay with the frontend so focused text inputs and page clipboard routing keep ownership. c("edit.copy", "Copy", EDIT, None, Copying, "copy"), + // Comments travel between documents as XFDF (M14): ⌘C with comments selected copies them, + // ⌘V pastes the copies onto the current page; the strip's Copy/Paste in the menus too. + c("comment.copy", "Copy comments", EDIT, None, Document, "copy"), + c("comment.paste", "Paste comments", EDIT, None, Annotate, "clipboard-paste"), c("edit.column_select", "Column select", EDIT, None, Document, "text-select"), c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"), c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"), diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index c2cb1b3fd2..e398604766 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -570,6 +570,15 @@ pub enum Edit { name: String, bytes: Arc>, }, + /// Paste copied comments (XFDF from [`Session::export_comments`]) onto `page` as new + /// comments: copies under names of their own, so nothing is replaced and pasting twice + /// gives two sets; replies keep their copied parent. + PasteComments { + xfdf: String, + page: usize, + /// Where the copies go: moved by (dx, dy) in user space, or at their own positions. + offset: Option<[f64; 2]>, + }, /// Remove Hidden Information: the chosen categories. RemoveHidden { which: Vec, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index e037ebc620..0c7046cdbb 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -71,6 +71,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Add stamp", "Add 3D model", "Delete comment", + "Paste comments", "Edit comment", "Reply", "Set status None", @@ -211,6 +212,7 @@ impl Edit { Edit::AddCustomStamp { .. } => Label::of("Add stamp"), Edit::AddModel3d { .. } => Label::of("Add 3D model"), Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), + Edit::PasteComments { .. } => Label::of("Paste comments"), Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), Edit::ReplyToAnnotation { .. } => Label::of("Reply"), // The state's name is part of the wording: a language may need to agree it with the rest. diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 39c9ace3e0..27eee7e428 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -49,6 +49,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re } Edit::AddMeasurement(_) | Edit::AddAnnotation(_) + | Edit::PasteComments { .. } | Edit::AddCustomStamp { .. } | Edit::AddModel3d { .. } | Edit::DeleteAnnotation { .. } @@ -496,6 +497,14 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::AddAnnotation(a) => { pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; } + Edit::PasteComments { xfdf, page, offset } => { + // Names from the edit's own id generator: unique to this paste within the document. + let prefix = cx.meta().id; + let report = pdfcraft_xfdf::import_comments(doc, xfdf, Some(*page), Some(&prefix), *offset)?; + if report.comments == 0 { + return Err(EditError::Invalid("the clipboard holds no comments".into())); + } + } Edit::AddCustomStamp { page, rect, name, file, author } => { let src = mark_source(doc, file)?; let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 12c31dd7a6..643f1d66a3 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -53,6 +53,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, Edit::AddMeasurement(_) | Edit::AddAnnotation(_) + | Edit::PasteComments { .. } | Edit::AddCustomStamp { .. } | Edit::AddModel3d { .. } | Edit::DeleteAnnotation { .. } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2daa1da400..ca25500ec0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -129,6 +129,7 @@ pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; pub use pdfcraft_sign as sign; pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; pub use pdfcraft_xfdf::Format as DataFormat; +pub use pdfcraft_xfdf::{Outline as CommentOutline, comment_count, comment_outlines, comments_bounds, is_xfdf_comments}; use bookmarks::outline_rows; pub use bookmarks::{BookmarkPage, BookmarkRow}; diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 553ddd6aa3..06e6acbfd1 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -114,6 +114,19 @@ impl Session { .map_err(EditError::Write) } + /// XFDF with these comments (0-based page, position in the page's `/Annots`) and their + /// replies: what Copy puts on the clipboard; [`Edit::PasteComments`] takes it back in. + pub fn export_comments(&self, id: DocId, which: &[(usize, usize)]) -> Result { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + guard(|| pdfcraft_xfdf::export_xfdf_comments(&cos, which, &doc.name)).map_err(EditError::Write) + } + /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5d9e5e971d..a20314c2e0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10185,6 +10185,42 @@ fn need_appearances_buttons_are_displayed_from_their_captions() { } } +/// Copy and paste: the copies of a comment and its reply land on the asked-for page of another +/// document under names of their own (nothing replaced; a second paste gives a second set), +/// undo takes a paste away as one step, and a clipboard without comments is refused. +#[test] +fn pasting_copied_comments_adds_copies_that_undo_as_one_step() { + let (mut s, id) = session_with(2); + s.apply(id, rect_comment(1, [80.0, 80.0, 120.0, 120.0])).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "Done".into(), author: "Ada".into() }).unwrap(); + let xfdf = s.export_comments(id, &[(1, 0)]).unwrap(); + assert!(xfdf.contains(" &'static str { } enum ClipboardPaste { - Text, + Text(String), Image(std::sync::Arc), } @@ -27,7 +27,8 @@ impl PdfCraftApp { false } - /// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus). + /// ⌘C / ⌘X / ⌘V copy, cut and paste text, images or comments on canvas (unless a text field + /// has focus). With no text selected, ⌘C copies the selected comments, each with its replies. pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) { use egui::{Key, KeyboardShortcut, Modifiers}; let pastes: Vec<_> = ctx.input(|input| { @@ -35,7 +36,7 @@ impl PdfCraftApp { .events .iter() .filter_map(|event| match event { - egui::Event::Paste(_) => Some(ClipboardPaste::Text), + egui::Event::Paste(text) => Some(ClipboardPaste::Text(text.clone())), egui::Event::PasteImage(image) => Some(ClipboardPaste::Image(image.clone())), _ => None, }) @@ -52,7 +53,9 @@ impl PdfCraftApp { || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X))); let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V))); + let text_selected = copy && self.views.get(i).and_then(|v| v.selected_text()).is_some_and(|t| !t.is_empty()); if copy + && (text_selected || !self.copy_selected_comments(i)) && !self.copy_selected_text(i, ctx) && let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone()) && let Some(id) = self.views.get(i).map(|v| v.id) @@ -89,16 +92,21 @@ impl PdfCraftApp { } } - fn receive_pastes(&mut self, view: usize, pastes: Vec, images_allowed: bool) { + /// `on_canvas`: the paste is the canvas's (no text field, dialog or page grid has it), so + /// pictures and copied comments land on the page. + fn receive_pastes(&mut self, view: usize, pastes: Vec, on_canvas: bool) { let page = self.views.get(view).map_or(0, |view| view.current); for paste in pastes { match paste { - ClipboardPaste::Text => { + ClipboardPaste::Text(text) => { // Text fields may own the paste; the canvas cache still observes its contents. self.last_copied_image = None; self.last_copied_file = None; + if on_canvas { + self.paste_comment_text(view, text); + } } - ClipboardPaste::Image(image) if images_allowed => { + ClipboardPaste::Image(image) if on_canvas => { self.paste_received_image(page, &image); } ClipboardPaste::Image(_) => {} @@ -106,6 +114,82 @@ impl PdfCraftApp { } } + /// A text pasted onto the canvas: XFDF with comments is pasted as comments. Copied comments + /// come from the system clipboard when it holds them, from the app's own only when it holds + /// nothing legible (a word copied in another app since is that word, not the comments copied + /// before it). + fn paste_comment_text(&mut self, i: usize, text: String) { + if pdfcraft_engine::is_xfdf_comments(&text) { + self.paste_comments(i, Some(text), None); + } else if text.trim().is_empty() && self.comment_clipboard.is_some() { + self.paste_comments(i, None, None); + } + } + + /// Copy the selected comments of tab `i` as XFDF, each with its replies: into the app's + /// clipboard (for ⌘V in any tab or window) and onto the system clipboard as text. False + /// when nothing is selected. + pub fn copy_selected_comments(&mut self, i: usize) -> bool { + let Some(view) = self.views.get(i) else { return false }; + let keys = view.comments.selection(); + if keys.is_empty() { + return false; + } + let id = view.id; + match self.session.export_comments(id, &keys) { + Ok(xfdf) => { + if let Some(ctx) = &self.ctx { + ctx.copy_text(xfdf.clone()); + } + self.comment_clipboard = Some(CommentClip { xfdf, count: keys.len() }); + true + } + Err(e) => { + self.notify_error(e); + false + } + } + } + + /// Paste copied comments (`text`, XFDF from the system clipboard, or else the app's + /// clipboard) into tab `i`. With `at` (a page and a user-space point, from the page's menu) + /// the bundle is placed at once, centred there; otherwise it hangs on the pointer as one + /// bundle until a click places it (`Gesture::Place`), so a copy never lands on its original + /// and a bundle from a large page goes where it fits on a small one. The copies are selected + /// once placed (see `apply_edit`). + pub fn paste_comments(&mut self, i: usize, text: Option, at: Option<(usize, [f64; 2])>) -> bool { + let Some(xfdf) = text.or_else(|| self.comment_clipboard.as_ref().map(|c| c.xfdf.clone())) else { + self.notify_tr("Copy comments first"); + return false; + }; + let Some(view) = self.views.get(i) else { return false }; + let id = view.id; + let Some(doc) = self.session.get(id) else { return false }; + if !doc.allows_annotation() { + self.notify_tr("The document's security settings don't allow this change"); + return false; + } + let count = pdfcraft_engine::comment_count(&xfdf); + let Some(bounds) = pdfcraft_engine::comments_bounds(&xfdf).filter(|_| count > 0) else { + self.notify_tr("The clipboard holds no comments"); + return false; + }; + match at { + Some((page, [x, y])) => { + let offset = [x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0]; + self.apply_edit(pdfcraft_engine::Edit::PasteComments { xfdf, page, offset: Some(offset) }) + } + None => { + let Some(view) = self.views.get_mut(i) else { return false }; + view.comments.clear_selection(); + let outlines = pdfcraft_engine::comment_outlines(&xfdf); + view.comments.gesture = Some(comments::Gesture::Place { xfdf, bounds, count, outlines }); + self.notify_tr("Click where the copied comments go; Escape lets them go"); + true + } + } + } + /// Shared permission gate for copying selected page text from menus and keyboard events. /// After Select All of the document (#2639), pages whose text wasn't read for display are read /// off the UI thread ([`DocumentCopy`]), and the clipboard gets the text when they're done. diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index a49617ee85..35a214d9aa 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -393,7 +393,7 @@ impl PdfCraftApp { let (p, i) = value.split_once(':').ok_or("comment: PAGE:INDEX")?; let (p, i): (usize, usize) = (p.trim().parse().map_err(|_| "comment: PAGE:INDEX")?, i.trim().parse().map_err(|_| "comment: PAGE:INDEX")?); - v.comments.selected = Some((p.saturating_sub(1), i.saturating_sub(1))); + v.comments.select_only((p.saturating_sub(1), i.saturating_sub(1))); v.comments.reveal = true; } // `--combine-view list`: Combine files as thumbnails (grid) or as the table (list). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 96210fb2fd..2207adbf59 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -787,6 +787,8 @@ pub(crate) enum PageMenuAction { MarqueeZoom, Copy, Paste(usize, [f64; 2]), + /// Paste the copied comments, centred at this point of the page. + PasteComments(usize, [f64; 2]), AddComment(usize, [f64; 2]), AddBookmark, TakeSnapshot, @@ -808,6 +810,8 @@ pub(crate) struct PageMenuPerms { pub can_copy: bool, pub can_copy_items: bool, pub can_paste: bool, + /// Copied comments wait to be pasted, and comments may be added. + pub can_paste_comments: bool, pub has_annotations: bool, } @@ -849,6 +853,9 @@ pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64 ui, A::Paste(page, at), ); + if perms.can_paste_comments { + pick(ui.button(tl!("Paste comments")), ui, A::PasteComments(page, at)); + } ui.separator(); @@ -1156,6 +1163,10 @@ struct PageEnv<'a> { by_line: &'a String, calc_order: bool, can_copy: bool, + /// Copied comments wait to be pasted. + can_paste_comments: bool, + /// Copied comments hang on the pointer (`Gesture::Place`): the click is theirs. + placing: bool, can_crop: bool, can_fill: bool, can_modify: bool, @@ -1212,6 +1223,8 @@ struct PageEnv<'a> { struct PageOut { canvas_action: Option, clicked_link: Option, + comment_notice: Option<&'static str>, + copy_comments: bool, copy_selected: bool, field_menu: Option, field_placed: bool, @@ -1223,6 +1236,7 @@ struct PageOut { open_signature: bool, open_table: Option<(usize, [f64; 4])>, page_action: Option, + paste_comments_at: Option<(usize, [f64; 2])>, primary_down: bool, refused: Option, /// The current page or spread where it is drawn, every page of it: one off screen (the second @@ -1603,6 +1617,7 @@ fn draw_pages( coarse_scroll, calc_order, can_copy, + can_paste_comments, can_modify, content_h, content_w, @@ -1799,6 +1814,7 @@ fn draw_pages( refused, row_on_screen: _, stamp_placed, + .. } = page_out; let mut page_action: Option = None; let painter = ui.painter(); @@ -1960,8 +1976,18 @@ fn draw_pages( crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation); return; } - if view.comments.selected.is_some() { - canvas_action = comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify); + // A selected comment, or connected lines being drawn (Complete, Undo last point): the + // comment menu. + let drawing_points = matches!(&view.comments.gesture, Some(comments::Gesture::Draw { tool, .. }) if tool.clicks_points()); + if view.comments.selected.is_some() || drawing_points { + canvas_action = comments::context_menu( + ui, + view, + info, + &|page, index| doc.annotation_deletable(page, index), + prefs, + comments::MenuRights { allowed, can_paste: can_paste_comments, can_modify }, + ); return; } // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. @@ -2005,6 +2031,7 @@ fn draw_pages( can_copy, can_copy_items, can_paste, + can_paste_comments: allowed && can_paste_comments, has_annotations: !info.annotations.is_empty(), }; default_page_context_menu(ui, page, at, perms, &mut page_action); @@ -2012,9 +2039,15 @@ fn draw_pages( if !resp.context_menu_opened() { ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false)); } + // Copy comments / Paste comments from the page's menu or a Comments panel card. + let copy_comments = std::mem::take(&mut view.comments.copy_request); + let paste_comments_at = view.comments.paste_request.take(); + let comment_notice = view.comments.notice.take(); *out = PageOut { canvas_action, clicked_link, + comment_notice, + copy_comments, copy_selected, field_menu, field_placed, @@ -2026,6 +2059,7 @@ fn draw_pages( open_signature, open_table, page_action, + paste_comments_at, primary_down: ptr.primary_down, refused, row_on_screen, @@ -2808,6 +2842,15 @@ fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> V glide } +/// The tool the page sees this frame, and whether copied comments hang on the pointer. Those +/// take the next click whatever tool is on: the page sees the Select tool until the bundle is +/// placed or let go, and no form field, added content or field being prepared takes the click +/// either. +fn page_tool(view: &DocView, chosen: QuickTool) -> (QuickTool, bool) { + let placing = matches!(view.comments.gesture, Some(comments::Gesture::Place { .. })); + (if placing { QuickTool::Select } else { chosen }, placing) +} + /// Text selection runs for the Select tool and for the markup tools (highlight…). fn tool_selects_text(tool: QuickTool, preparing: bool) -> bool { match tool { @@ -3010,7 +3053,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let sharp_held = hold_sharp_renders(ui, view, scale); view.current_scale_tag = tag; let hand = app.quick_tool == QuickTool::Hand; - let tool = app.quick_tool; + let (tool, placing) = page_tool(view, app.quick_tool); // Text selection runs for the Select tool and for the markup tools (highlight…). let selects_text = tool_selects_text(tool, preparing); let prefs = &app.comment_prefs; @@ -3018,6 +3061,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let units = app.units; let allowed = doc.allows_annotation(); let can_copy = doc.allows_copying(); + let can_paste_comments = app.comment_clipboard.is_some(); let comments_hidden = doc.comments_hidden(); let form = doc.form.clone(); let generation = doc.edit_generation(); @@ -3030,11 +3074,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // In Preferences ▸ Date format, or why it can't be stamped into the PDF. let date_text = crate::date_text_for_pdf(&app.session); let by_line = app.session.stamp_by_line(&author); - let custom_stamp = match app.quick_tool { + let custom_stamp = match tool { QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), _ => None, }; let mut open_props: Option<(usize, usize)> = None; + let mut recolour: Option = None; // The table being set up in its window, shown on its own document's page. let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); let can_modify = doc.allows_modification(); @@ -3066,6 +3111,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { by_line: &by_line, calc_order, can_copy, + can_paste_comments, + placing, can_crop, can_fill, can_modify, @@ -3123,6 +3170,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let PageOut { canvas_action, clicked_link, + comment_notice, + copy_comments, mut copy_selected, field_menu, field_placed, @@ -3134,6 +3183,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { open_signature, open_table, page_action, + paste_comments_at, primary_down, mut refused, row_on_screen, @@ -3218,6 +3268,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { app.comments_panel_closed = false; } Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)), + Some(comments::CanvasAction::Colour(c)) => recolour = Some(c), Some(comments::CanvasAction::CopyImage(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::Copy(p, i)), Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)), None => {} @@ -3242,6 +3293,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { open_table, form_notice, copy_selected, + copy_comments, + paste_comments_at, + comment_notice, + recolour, remote_open, form, generation, @@ -3264,6 +3319,13 @@ struct FrameRequests { open_table: Option<(usize, [f64; 4])>, form_notice: Option, copy_selected: bool, + /// Copy comments / Paste comments from the page's menu or a Comments panel card, and what + /// the comment tools have to say. + copy_comments: bool, + paste_comments_at: Option<(usize, [f64; 2])>, + comment_notice: Option<&'static str>, + /// A colour picked for every selected comment (Colour in the page's menu). + recolour: Option, remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)>, form: Arc>, generation: u64, @@ -3286,6 +3348,10 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, open_table, form_notice, copy_selected, + copy_comments, + paste_comments_at, + comment_notice, + recolour, remote_open, form, generation, @@ -3297,6 +3363,9 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, if let Some((p, i)) = open_props { app.open_comment_props(p, i); } + if let Some(c) = recolour { + app.colour_selected_comments(c); + } if let Some((p, i)) = app.views[index].comments.default_request.take() { app.make_comment_default(p, i); } @@ -3392,6 +3461,9 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, Some(PageMenuAction::Paste(page, at)) => { app.paste_image_at(page, at); } + Some(PageMenuAction::PasteComments(page, at)) => { + app.paste_comments(index, None, Some((page, at))); + } Some(PageMenuAction::RotateCCW) => { app.views[index].rotate_view(false); } @@ -3480,6 +3552,15 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, if copy_selected { app.execute("edit.copy"); } + if copy_comments { + app.execute("comment.copy"); + } + if let Some(at) = paste_comments_at { + app.paste_comments(index, None, Some(at)); + } + if let Some(notice) = comment_notice { + app.notify_tr(notice); + } // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459); // the quick tools come back with the window. if app.mode != crate::Mode::Read && !app.full_screen { diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 03bd3e807d..d366967b9f 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -51,6 +51,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut editing_content, form, info, + placing, prefs, preparing, redact_mark, @@ -116,14 +117,14 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let over_mark = matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); tool_input(ui, view, frame, slot, &pcx, press, over_mark); - let on_field = if preparing { + let on_field = if preparing && !placing { let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view); frame.out.field_props |= o.properties; frame.out.field_placed |= o.placed; frame.out.open_table = frame.out.open_table.or(o.table); o.consumed || field_tool.is_some() } else { - tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, press, released_over, view) + tool == QuickTool::Select && !placing && crate::forms_ui::page_input(cx, form, can_fill, press, released_over, view) }; // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; @@ -150,7 +151,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } view.content.selected = None; } - let on_content = !on_objects && editing_content && can_modify && { + let on_content = !on_objects && !placing && editing_content && can_modify && { source_blocked || crate::content_ui::page_input( cx, diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index b1114f283e..4601986e9a 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -26,6 +26,7 @@ pub(crate) const NEXT_VIEW: Shortcut = Shortcut::cmd("]"); pub(crate) const FIND_NEXT: Shortcut = Shortcut::cmd("G"); pub(crate) const FIND_PREV: Shortcut = Shortcut::cmd_shift("G"); pub(crate) const COPY: Shortcut = Shortcut::cmd("C"); +pub(crate) const PASTE: Shortcut = Shortcut::cmd("V"); pub(crate) const SELECT_ALL: Shortcut = Shortcut::cmd("A"); pub(crate) const PAGE_PREV: Shortcut = Shortcut::cmd("←"); pub(crate) const PAGE_NEXT: Shortcut = Shortcut::cmd("→"); @@ -95,6 +96,8 @@ impl PdfCraftApp { .is_some_and(|v| !v.organize && (v.all_text || v.selected_text().is_some_and(|t| !t.is_empty())))) && (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies)) && match spec.id { + "comment.copy" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| v.comments.selected.is_some()), + "comment.paste" => self.comment_clipboard.is_some(), "view.previous_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.back.is_empty()), "view.next_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.forward.is_empty()), _ => true, @@ -570,6 +573,14 @@ impl PdfCraftApp { } return Some(false); } + "comment.copy" => { + let Some(i) = active else { return Some(false) }; + return Some(self.copy_selected_comments(i)); + } + "comment.paste" => { + let Some(i) = active else { return Some(false) }; + return Some(self.paste_comments(i, None, None)); + } "comment.hide_all" => { if let Some(i) = active { let id = self.views[i].id; diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index b803f2738c..c2e82f98e5 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -96,6 +96,8 @@ impl Host for crate::PdfCraftApp { // Where pages are on screen (points), to aim ui.click / ui.drag at page content. "pages_on_screen": v.visible_page_rects().iter().map(|(p, r)| json!({ "page": p + 1, "rect": [r.min.x, r.min.y, r.max.x, r.max.y] })).collect::>(), "selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })), + // Every selected comment (⌘-click adds), the one with the handles first. + "selected_comments": v.comments.selection().iter().map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })).collect::>(), "comment_composer_open": v.comments.composer.is_some(), })), // The Combine files tab: how its files show, and each grid thumbnail's state. diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index db08b7b918..285423cbca 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -26,6 +26,9 @@ pub struct PropsDraft { /// Acrobat's Make Properties Default box: on OK, new comments of this kind take the edited /// colour, opacity and line width too. pub make_default: bool, + /// The other selected comments, each with its own properties: OK makes the same changes to + /// them where they apply (a colour to every one, a font size to text boxes, …). + pub others: Vec<(usize, usize, CommentProps)>, } const ICONS: [NoteIcon; 14] = NoteIcon::ALL; @@ -121,11 +124,44 @@ impl PdfCraftApp { self.notify_fmt("New {tool} comments will look like this one", &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))]); } - /// Open Comment Properties for the comment at `(page, index)` of the active document. + /// Give every selected comment of the active document `color`, as one undo step. + pub fn colour_selected_comments(&mut self, color: pdfcraft_engine::Rgb) { + self.colour_selection(color, |_| true); + } + + /// Give the selected comments that `only` picks `color`, as one undo step: those whose look can + /// change and that aren't locked (a locked one would refuse the whole step). + pub(crate) fn colour_selection(&mut self, color: pdfcraft_engine::Rgb, only: impl Fn(&pdfcraft_render::Annotation) -> bool) { + let Some((i, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let keys = self.views.get(i).map(|v| v.comments.selection()).unwrap_or_default(); + let mut edits: Vec = keys + .into_iter() + .filter(|(p, ix)| doc.info.annotations.iter().any(|a| a.page == *p && a.index == *ix && !a.is_reply() && only(a))) + .filter(|(p, ix)| doc.comment_props(*p, *ix).is_some_and(|c| c.restylable && !c.locked)) + .map(|(page, index)| Edit::StyleAnnotation { page, index, color: Some(color), opacity: None, width: None, endings: None }) + .collect(); + match edits.len() { + 0 => {} + 1 => { + self.apply_edit(edits.remove(0)); + } + _ => { + self.apply_edit(Edit::Batch { label: "Change comment properties".into(), edits }); + } + } + } + + /// Open Comment Properties for the comment at `(page, index)` of the active document. When it + /// is one of several selected comments, the changes go to all of them. pub fn open_comment_props(&mut self, page: usize, index: usize) { - let Some((_, id)) = self.active_ids() else { return }; - let Some(p) = self.session.get(id).and_then(|d| d.comment_props(page, index)) else { return }; - self.comment_props = Some(PropsDraft { page, index, tab: PropsTab::Appearance, original: p.clone(), edited: p, make_default: false }); + let Some((i, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let Some(p) = doc.comment_props(page, index) else { return }; + let selection = self.views.get(i).map(|v| &v.comments).filter(|c| c.is_selected((page, index))).map(|c| c.selection()).unwrap_or_default(); + let others = + selection.into_iter().filter(|k| *k != (page, index)).filter_map(|(pg, ix)| doc.comment_props(pg, ix).map(|c| (pg, ix, c))).collect(); + self.comment_props = Some(PropsDraft { page, index, tab: PropsTab::Appearance, original: p.clone(), edited: p, make_default: false, others }); self.dialog = Some(crate::Dialog::CommentProps); } } @@ -144,48 +180,81 @@ fn has_border(p: &CommentProps) -> bool { p.font_size.is_some() && p.width.is_some_and(|w| w > 0.0) } -/// The edits that make the comment match the draft (empty when nothing changed). +/// The edits that make the comment match the draft (empty when nothing changed), and make the +/// same changes to the other selected comments where they apply. Locks come off first and go on +/// last, as a locked comment refuses other changes; another comment that stays locked is left +/// as it is. pub fn edits(d: &PropsDraft) -> Vec { let (o, e) = (&d.original, &d.edited); + let targets: Vec<(usize, usize, &CommentProps)> = + std::iter::once((d.page, d.index, o)).chain(d.others.iter().map(|(p, i, c)| (*p, *i, c))).collect(); let mut out = Vec::new(); - // A locked comment refuses other changes: unlock first, lock last. - if o.locked && !e.locked { - out.push(Edit::LockAnnotation { page: d.page, index: d.index, locked: false }); + // Unticking Locked unlocks every selected comment. + let unlocking = o.locked && !e.locked; + for &(page, index, t) in &targets { + if unlocking && t.locked { + out.push(Edit::LockAnnotation { page, index, locked: false }); + } } + for (n, &(page, index, t)) in targets.iter().enumerate() { + if n > 0 && t.locked && !unlocking { + continue; + } + changes(o, e, page, index, t, &mut out); + } + for &(page, index, t) in &targets { + if !o.locked && e.locked && !t.locked { + out.push(Edit::LockAnnotation { page, index, locked: true }); + } + } + out +} + +/// The changes from `o` to `e` (the draft) for the comment at `(page, index)`, whose own +/// properties are `t`: only those its kind has (a line width for shapes and lines, a fill for +/// shapes, a font size for text, …). +fn changes(o: &CommentProps, e: &CommentProps, page: usize, index: usize, t: &CommentProps, out: &mut Vec) { let color = (e.color != o.color).then_some(e.color).flatten(); let opacity = ((e.opacity - o.opacity).abs() > 1e-6).then_some(e.opacity); - let width = (e.width != o.width).then_some(e.width).flatten(); - let endings = (e.endings != o.endings).then(|| e.endings.clone()).flatten(); - if e.restylable && (color.is_some() || opacity.is_some() || width.is_some() || endings.is_some()) { - out.push(Edit::StyleAnnotation { page: d.page, index: d.index, color, opacity, width, endings }); + let width = (e.width != o.width).then_some(e.width).flatten().filter(|_| t.width.is_some()); + let endings = + (e.endings != o.endings).then(|| e.endings.clone()).flatten().filter(|ends| t.endings.as_ref().is_some_and(|own| own.len() == ends.len())); + if t.restylable && (color.is_some() || opacity.is_some() || width.is_some() || endings.is_some()) { + out.push(Edit::StyleAnnotation { page, index, color, opacity, width, endings }); } - if e.restylable && e.fillable && e.fill != o.fill { - out.push(Edit::FillAnnotation { page: d.page, index: d.index, fill: e.fill }); + if t.restylable && t.fillable && e.fill != o.fill { + out.push(Edit::FillAnnotation { page, index, fill: e.fill }); } - // After the opacity, which is the whole comment's: the parts shown are set again. - let (fill, text) = parts_shown(e); + // After the opacity, which is the whole comment's: the parts shown are set again. Which + // parts show is the comment's own once the draft's fill and line width are on it, so another + // selected comment without a border or a fill keeps one opacity for the whole comment. + let mut after = t.clone(); + if t.fillable && e.fill != o.fill { + after.fill = e.fill; + } + if let Some(w) = width { + after.width = Some(w); + } + let (fill, text) = parts_shown(&after); let moved = |a: Option, b: Option| a.zip(b).is_some_and(|(a, b)| (a - b).abs() > 1e-6); - if e.restylable + if t.restylable && (fill || text) && (opacity.is_some() || fill && moved(e.fill_opacity, o.fill_opacity) || text && moved(e.text_opacity, o.text_opacity)) { - out.push(Edit::PartOpacity { page: d.page, index: d.index, fill: e.fill_opacity.filter(|_| fill), text: e.text_opacity.filter(|_| text) }); + out.push(Edit::PartOpacity { page, index, fill: e.fill_opacity.filter(|_| fill), text: e.text_opacity.filter(|_| text) }); } - if e.restylable + if t.restylable + && t.font_size.is_some() && let Some(size) = e.font_size.filter(|s| Some(*s) != o.font_size) { - out.push(Edit::FontSizeAnnotation { page: d.page, index: d.index, size }); + out.push(Edit::FontSizeAnnotation { page, index, size }); } let author = (e.author != o.author).then(|| e.author.clone()); let subject = (e.subject != o.subject).then(|| e.subject.clone()); - let icon = (e.icon != o.icon).then_some(e.icon).flatten(); + let icon = (e.icon != o.icon).then_some(e.icon).flatten().filter(|_| t.icon.is_some()); if author.is_some() || subject.is_some() || icon.is_some() { - out.push(Edit::SetAnnotationInfo { page: d.page, index: d.index, author, subject, icon }); + out.push(Edit::SetAnnotationInfo { page, index, author, subject, icon }); } - if !o.locked && e.locked { - out.push(Edit::LockAnnotation { page: d.page, index: d.index, locked: true }); - } - out } /// Draw the dialog; returns (apply, cancel). @@ -213,6 +282,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let Some(d) = app.comment_props.as_mut() else { return (false, true) }; let kind = tl!(crate::comments_panel::subtype_label(&d.edited.subtype)).to_string(); ui.label(egui::RichText::new(crate::i18n::fmt(tl!("{kind} Properties"), &[("kind", &kind)])).font(theme::semibold(18.0))); + if !d.others.is_empty() { + let n = (d.others.len() + 1).to_string(); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Changes here apply to all {n} selected comments"), &[("n", &n)])).color(t.text_muted)); + } ui.add_space(6.0); ui.horizontal_wrapped(|ui| { for (tab, label) in diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 8597e07b5a..6a788e765d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -28,7 +28,7 @@ fn new_text_typed(v: &crate::DocView) -> bool { } /// Keeps the pages (or comment) the user acted on selected, where they now are. `annots_before` -/// is a deleted annotation's page's annotations before the delete (empty for other edits). +/// is the page's annotations before a delete or a paste of comments (empty for other edits). fn select_after_edit( view: &mut crate::DocView, edit: &Edit, @@ -58,11 +58,12 @@ fn select_after_edit( crate::QuickTool::Comment(crate::comments::CommentTool::Ink | crate::comments::CommentTool::FreehandHighlight) ) => { - view.comments.selected = None; + view.comments.clear_selection(); } Edit::AddAnnotation(a) => { - // Select the new comment (appended last among the page's comments). + // Select the new comment alone (appended last among the page's comments). let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max(); + view.comments.clear_selection(); view.comments.selected = newest.map(|n| (a.page, n)); view.comments.reveal = true; // A new highlight opens its note for typing straight away, as in Acrobat, @@ -75,6 +76,25 @@ fn select_after_edit( } } Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)), + // A group moved together stays selected (its positions don't change), so it + // can be moved or copied again; anything else in a batch clears the selection. + Edit::Batch { edits, .. } if edits.iter().all(|e| matches!(e, Edit::MoveAnnotation { .. })) => {} + Edit::Batch { .. } => view.comments.clear_selection(), + // The copies the paste added (and nothing that was there before): select them all. + Edit::PasteComments { page, .. } => { + let mut new: Vec<(usize, usize)> = info + .annotations + .iter() + .filter(|x| x.page == *page && !x.is_reply() && !annots_before.iter().any(|b| b.name == x.name)) + .map(|x| (*page, x.index)) + .collect(); + view.comments.clear_selection(); + if !new.is_empty() { + view.comments.selected = Some(new.remove(0)); + view.comments.also = new; + view.comments.reveal = true; + } + } _ => {} } } @@ -142,7 +162,10 @@ impl PdfCraftApp { // A deletion moves the comments after it on the page: these say where the selected one, // and the one open in the panel's editor, went. let annots_before = match (&edit, self.session.get(id)) { - (Edit::DeleteAnnotation { page, .. }, Some(d)) => d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect(), + // A paste selects what it adds: the page's comments before it are the ones it didn't. + (Edit::DeleteAnnotation { page, .. } | Edit::PasteComments { page, .. }, Some(d)) => { + d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect() + } _ => Vec::new(), }; match self.session.apply(id, edit.clone()) { diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index 269f0a12c2..f27b1e035b 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -368,19 +368,12 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve } if let QuickTool::Comment(tool) = app.quick_tool { controls_divider(ui, t, vertical); + // The selected comments this tool made take the colour too (locked ones keep theirs). if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs) - && let Some((index, id)) = app.active_ids() - && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) - && app.session.get(id).is_some_and(|doc| { - doc.allows_annotation() - && doc - .info - .annotations - .iter() - .any(|a| a.page == page && a.index == annotation && !a.is_reply() && !a.locked && comments::made_by(a, tool)) - }) + && let Some((_, id)) = app.active_ids() + && app.session.get(id).is_some_and(|doc| doc.allows_annotation()) { - app.apply_edit(Edit::StyleAnnotation { page, index: annotation, color: Some(color), opacity: None, width: None, endings: None }); + app.colour_selection(color, |a| comments::made_by(a, tool)); } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d9de15d046..5b6465f1df 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -147,8 +147,9 @@ use state::CopyPage; #[cfg(not(target_arch = "wasm32"))] use state::OpenRequest; pub use state::{ - CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, PasswordPrompt, - PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel, UnlockPrompt, + CommentClip, CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, + PasswordPrompt, PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel, + UnlockPrompt, }; use state::{DocumentCopyJob, GridFile}; pub use tools::bulk_fields; @@ -561,6 +562,8 @@ pub struct PdfCraftApp { pub progress_notice: Option, /// Pages copied or cut in Organize Pages, ready to paste (into any document). pub page_clipboard: Option, + /// Copied comments (XFDF), to paste into this or another document (M14). + pub comment_clipboard: Option, /// Files dropped on the page grid, waiting for the pointer to say which gap they go to. grid_drop: Option, /// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 49f2aeba1a..369ad5933d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -141,6 +141,9 @@ const EDIT: &[&str] = &[ PASTE, DELETE, SEP, + "comment.copy", + "comment.paste", + SEP, SELECT_ALL, SEP, "edit.find", diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index 591a667159..0ac648d59c 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -239,6 +239,9 @@ pub(crate) fn show( { view.comments.collapsed_pages.retain(|&c| c != p); } + // The cards in the order shown, for a ⇧-click range (folded pages' cards aren't shown). + let order: Vec<(usize, usize)> = + roots.iter().filter(|a| !(sort == SortBy::Page && view.comments.collapsed_pages.contains(&a.page))).map(|a| (a.page, a.index)).collect(); let mut page = usize::MAX; let mut group: Option = None; // A card (or page header) out of view that was measured at this width only takes up its @@ -287,7 +290,7 @@ pub(crate) fn show( } let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation); let top = ui.cursor().min.y; - if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav }) { + if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, order: &order, doc, prefs, allowed, nav }) { edit = Some(e); } if virtual_list { @@ -376,6 +379,8 @@ struct CardContext<'a> { // The clockwise `/Rotate` of the comment's page. rotation: u16, thread: &'a [&'a Annotation], + /// The cards in the order shown, for a ⇧-click range. + order: &'a [(usize, usize)], doc: &'a pdfcraft_engine::Document, prefs: &'a CommentPrefs, allowed: bool, @@ -383,11 +388,14 @@ struct CardContext<'a> { } fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Option { - let CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav } = context; + let CardContext { theme: t, annotation: a, rotation, thread, order, doc, prefs, allowed, nav } = context; let info = &doc.info; let mut edit = None; let key = (a.page, a.index); - let selected = view.comments.selected == Some(key); + let selected = view.comments.is_selected(key); + // Several comments may be selected; the reply field (one draft) belongs to the one with + // the handles. + let primary = view.comments.selected == Some(key); let color = a.color.map(|c| color32(c.map(f64::from))).unwrap_or(t.accent); let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.as_deref()); let marked = is_marked(thread); @@ -504,7 +512,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti } above = me; } - if selected && allowed { + if primary && allowed { ui.add_space(8.0); egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 0, bottom: 0 }).show(ui, |ui| { // The field fills its line and Post sits right-aligned under it: a field sized @@ -552,7 +560,16 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text(crate::i18n::fmt(tl!("{kind} — click to show it on the page"), &[("kind", tl!(kind_label(a)))])); if click.clicked() { - view.comments.selected = Some(key); + // ⌘/Ctrl-click adds the comment to the selection, as on the page; ⇧-click selects the + // cards from the one with the handles to this one, as lists do. + let modifiers = ui.input(|i| i.modifiers); + if modifiers.shift && !modifiers.command { + view.comments.select_range(order, key); + } else if modifiers.command { + view.comments.toggle(key); + } else { + view.comments.select_only(key); + } view.comments.reply.clear(); *nav = Some(Nav::Flash(a.page, a.shown_rect(rotation))); } @@ -603,10 +620,16 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti view.comments.default_request = Some((a.page, a.index)); ui.close(); } + if ui.button(tl!("Copy comment")).clicked() { + // The card's comment alone (the page's menu copies the whole selection). + view.comments.select_only(key); + view.comments.copy_request = true; + ui.close(); + } ui.separator(); // A locked reply can't be deleted, so it keeps the comment it answers too. if ui.add_enabled(doc.annotation_deletable(a.page, a.index), egui::Button::new(tl!("Delete"))).clicked() { - view.comments.selected = None; + view.comments.clear_selection(); edit = Some(Edit::DeleteAnnotation { page: a.page, index: a.index }); ui.close(); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5d658a591e..915d40e6ec 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -233,6 +233,14 @@ pub struct PagePropsDraft { pub tab_order: Option, } +/// Copied comments: their XFDF (each with its replies), for `Edit::PasteComments`. +#[derive(Clone, Debug, PartialEq)] +pub struct CommentClip { + pub xfdf: String, + /// How many comments (replies not counted) were copied. + pub count: usize, +} + /// Copied pages: their document's name and bytes (as it was when copied) and the pages. #[derive(Clone, Debug)] pub struct PageClip { diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 348d4b906f..7e6d88b520 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -39,9 +39,9 @@ pub use geometry::{drawn_text_box_rect, text_box_rect}; use input::hover_tip; pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; +pub(crate) use menu::{MenuRights, attachment_items, context_menu, quick_bar_colour, quick_bar_controls}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; -pub(crate) use menu::{attachment_items, context_menu, quick_bar_colour, quick_bar_controls}; pub(crate) use paint::paint_page; #[cfg(test)] use paint::{changing_points, drawn_shape, edited_in_view}; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index caddd3e837..fa56f7a231 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -4,7 +4,7 @@ use super::geometry::{ }; use super::paint::{closes_at, drawn_shape, edit_anchor, erase, new_comment}; use super::state::{Composer, ComposerKind, Gesture, PageCx}; -use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; +use super::{Annotation, CommentPrefs, CommentTool, CommentView, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; use crate::page_cursor::{Layer, Subject}; use crate::page_router::{CommentPart, Hit, Owner, PagePress}; @@ -31,6 +31,39 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, cv.gesture = None; return true; } + // Copied comments on the pointer: a click places the bundle, centred where it is clicked, + // on this page; a right-click lets it go. Nothing else on the page takes the click. + if let Some(Gesture::Place { xfdf, bounds, .. }) = cv.gesture.clone() { + if over_page { + view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); + } + let cv = &mut view.comments; + if resp.secondary_clicked() { + cv.gesture = None; + return true; + } + if resp.clicked() + && over_page + && cx.allowed + && let Some(p) = pointer + { + let [x, y] = cx.to_user(p); + let offset = [x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0]; + // Larger than the page it lands on (an A0 bundle on an A4 page): placed as it is, + // and said so. + if let Some(page) = cx.info.pages.get(cx.page) { + let a = page.user_to_view((bounds[0] + offset[0]) as f32, (bounds[1] + offset[1]) as f32); + let b = page.user_to_view((bounds[2] + offset[0]) as f32, (bounds[3] + offset[1]) as f32); + let (w, h) = ((a[0] - b[0]).abs(), (a[1] - b[1]).abs()); + if w > page.width + 1.0 || h > page.height + 1.0 { + cv.notice = Some("The copied comments are larger than this page; they were placed as they are"); + } + } + cv.gesture = None; + view.pending_edit = Some(Edit::PasteComments { xfdf, page: cx.page, offset: Some(offset) }); + } + return true; + } let moving = moving_on(view, cx.page); let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free }; match cx.tool { @@ -216,7 +249,20 @@ fn select_input( consumed = true; } CommentPart::Body if !is_markup(&a.subtype) => { - cv.selected = Some((cx.page, a.index)); + // Dragging one of several selected comments moves them all; any other drag starts + // a selection of its own. + let grabbed = (cx.page, a.index); + if !cv.is_selected(grabbed) { + cv.select_only(grabbed); + } else { + // The grabbed one takes the handles; the one that had them stays selected. + if let Some(previous) = cv.selected.replace(grabbed) + && previous != grabbed + { + cv.also.push(previous); + } + cv.also.retain(|k| *k != grabbed); + } cv.reveal = true; if cx.allowed { cv.gesture = Some(Gesture::Move { page: cx.page, index: a.index, from: o }); @@ -232,38 +278,9 @@ fn select_input( if resp.drag_stopped() && let Some(p) = pointer { - match cv.gesture.clone() { - Some(Gesture::Move { page, index, from }) if page == cx.page => { - cv.gesture = None; - let (a, b) = (cx.to_user(from), cx.to_user(p)); - let (dx, dy) = (b[0] - a[0], b[1] - a[1]); - if from.distance(p) >= 2.0 { - view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy }); - } - } - Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => { - cv.gesture = None; - if let Some(a) = cx.get(index) { - let from_rect = cx.screen_rect(a); - let shift = ui.input(|i| i.modifiers.shift); - let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift)); - let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation()); - if r.width() >= 4.0 && r.height() >= 4.0 { - view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect }); - } - } - } - Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => { - cv.gesture = None; - let shift = ui.input(|i| i.modifiers.shift); - let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0); - if moved { - view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) }); - } - } - _ => {} - } + finish_select_gesture(ui, cx, view, p); } + let cv = &mut view.comments; // (egui clears the press origin on release, so clicks are located by the pointer.) let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p)); if resp.clicked() && on_page { @@ -271,11 +288,16 @@ fn select_input( // form field; added text) leaves comments unselected. match owner { Some((a, _)) => { - cv.selected = Some((cx.page, a.index)); + // ⌘/Ctrl-click (or ⇧-click) adds the comment to the selection, as in Acrobat. + if ui.input(|i| i.modifiers.command || i.modifiers.shift) { + cv.toggle((cx.page, a.index)); + } else { + cv.select_only((cx.page, a.index)); + } cv.reveal = true; consumed = true; } - None if press != PagePress::Elsewhere => cv.selected = None, + None if press != PagePress::Elsewhere => cv.clear_selection(), None => {} } } @@ -284,7 +306,7 @@ fn select_input( && cx.allowed && let Some((a, _)) = owner { - cv.selected = Some((cx.page, a.index)); + cv.select_only((cx.page, a.index)); cv.composer = Some(Composer { page: cx.page, at: edit_anchor(cx.info, a), @@ -297,6 +319,57 @@ fn select_input( consumed } +/// The end of a move, resize or reshape the Select tool began on this page: the edit it makes, +/// for the pointer released at `p`. +fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p: Pos2) { + match view.comments.gesture.clone() { + Some(Gesture::Move { page, index, from }) if page == cx.page => { + view.comments.gesture = None; + let (a, b) = (cx.to_user(from), cx.to_user(p)); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + if from.distance(p) >= 2.0 { + // The other selected comments on this page (not text markup, which is where + // its text is, nor a locked one, which would refuse the whole step) move with + // the dragged one, as one undo step. + let others: Vec = view + .comments + .also + .iter() + .filter(|(p, i)| *p == page && *i != index && cx.get(*i).is_some_and(|a| !is_markup(&a.subtype) && !a.locked)) + .map(|(_, i)| *i) + .collect(); + view.pending_edit = Some(if others.is_empty() { + Edit::MoveAnnotation { page, index, dx, dy } + } else { + let edits = std::iter::once(index).chain(others).map(|index| Edit::MoveAnnotation { page, index, dx, dy }).collect(); + Edit::Batch { label: tl!("Move comments").to_owned(), edits } + }); + } + } + Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => { + view.comments.gesture = None; + if let Some(a) = cx.get(index) { + let from_rect = cx.screen_rect(a); + let shift = ui.input(|i| i.modifiers.shift); + let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift)); + let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation()); + if r.width() >= 4.0 && r.height() >= 4.0 { + view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect }); + } + } + } + Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => { + view.comments.gesture = None; + let shift = ui.input(|i| i.modifiers.shift); + let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0); + if moved { + view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) }); + } + } + _ => {} + } +} + /// After text selection ran: a markup tool turns a finished selection into a comment; Replace /// Text asks for the replacement. pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) { @@ -421,7 +494,18 @@ impl Gestures<'_> { if over_page { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); + // The menu's Complete is taken by the page the drawing is on: every page on screen + // passes here in a frame, and an earlier one must not swallow it. With no drawing + // left, a request is stale. + let drawing_here = match &cv.gesture { + Some(Gesture::Draw { page, .. }) => *page == cx.page, + _ => { + cv.finish_request = false; + false + } + }; + let finish = + resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) || (drawing_here && std::mem::take(&mut cv.finish_request)); // Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too. // Only while a drawing is in progress: otherwise Delete belongs to the selected comment. if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page) @@ -615,17 +699,36 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool // One step back per press: cancel the gesture, then deselect, then drop the tool (a // comment tool, or a Fill & Sign one: its Text tool would otherwise open a box at every // click with no way out by the keyboard). - let cancelled = cv.gesture.take().is_some() || cv.selected.take().is_some(); + let had_selection = cv.selected.is_some(); + cv.clear_selection(); + let cancelled = cv.gesture.take().is_some() || had_selection; if !cancelled && matches!(tool, QuickTool::Comment(_) | QuickTool::Fill(_)) { *tool = QuickTool::Select; } } if allowed - && let Some((page, index)) = cv.selected + && cv.selected.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) + && let Some(edit) = delete_selected(cv) { - cv.selected = None; - view.pending_edit = Some(Edit::DeleteAnnotation { page, index }); + view.pending_edit = Some(edit); + } +} + +/// The edit that deletes every selected comment (one undo step for several), and clears the +/// selection. Later positions go first, so each deletion leaves the earlier ones where they are. +pub(crate) fn delete_selected(cv: &mut CommentView) -> Option { + let mut keys = cv.selection(); + cv.clear_selection(); + keys.sort_unstable_by(|a, b| b.cmp(a)); + keys.dedup(); + match keys.as_slice() { + [] => None, + [(page, index)] => Some(Edit::DeleteAnnotation { page: *page, index: *index }), + _ => Some(Edit::Batch { + label: tl!("Delete comments").to_owned(), + edits: keys.into_iter().map(|(page, index)| Edit::DeleteAnnotation { page, index }).collect(), + }), } } diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 58252c393a..ecbe36d6ae 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -1,5 +1,6 @@ +use super::input::delete_selected; use super::paint::edit_anchor; -use super::state::{Composer, ComposerKind}; +use super::state::{Composer, ComposerKind, Gesture}; use super::{ Annotation, Color32, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Rect, ReviewState, Rgb, SELECT_BLUE, SWATCHES, Sense, Stroke, color32, icons, theme, tool_for, vec2, @@ -22,8 +23,18 @@ pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &Doc ui.separator(); } +/// What the canvas context menu may offer. +#[derive(Clone, Copy, Debug)] +pub(crate) struct MenuRights { + /// Comments may be added and changed. + pub(crate) allowed: bool, + /// Copied comments wait to be pasted. + pub(crate) can_paste: bool, + /// The document's pages may change (Flatten comments draws into them). + pub(crate) can_modify: bool, +} + /// Items of the canvas context menu for the selected comment (or the page). -/// `can_modify`: the document's pages may change (Flatten comments draws into them). /// `deletable`: whether Delete would delete the comment at (page, index) (the engine's /// `Document::annotation_deletable`: unlocked, and no locked reply to it). pub(crate) fn context_menu( @@ -32,11 +43,32 @@ pub(crate) fn context_menu( info: &DocInfo, deletable: &dyn Fn(usize, usize) -> bool, prefs: &CommentPrefs, - allowed: bool, - can_modify: bool, + rights: MenuRights, ) -> Option { + let MenuRights { allowed, can_paste, can_modify } = rights; let mut action = None; + // Connected lines, a polygon or a cloud being drawn: finish it from the menu, as other + // editors do (Enter and a double-click finish it too), take the last point back, or stop. + if let Some(Gesture::Draw { tool, points, .. }) = &view.comments.gesture + && tool.clicks_points() + { + let enough = points.len() >= if *tool == CommentTool::PolyLine { 2 } else { 3 }; + if ui.add_enabled(enough, egui::Button::new(tl!("Complete"))).clicked() { + view.comments.finish_request = true; + ui.close(); + } + if ui.button(tl!("Undo last point")).clicked() { + view.comments.undo_point(); + ui.close(); + } + if ui.button(tl!("Cancel drawing")).clicked() { + view.comments.gesture = None; + ui.close(); + } + return None; + } let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && !a.is_reply())); + let several = view.comments.also.len(); match selected { Some(a) => { let (page, index) = (a.page, a.index); @@ -69,14 +101,19 @@ pub(crate) fn context_menu( let keep_open = egui::containers::menu::MenuConfig::new().close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); let _ = egui::containers::menu::SubMenuButton::new(tl!("Colour")).config(keep_open).ui(ui, |ui| { if let Some(c) = swatch_grid(ui, a.color.map(|c| c.map(f64::from))) { - action = Some(CanvasAction::Edit(Box::new(Edit::StyleAnnotation { - page, - index, - color: Some(c), - opacity: None, - width: None, - endings: None, - }))); + // Several selected: the app gives each of them the colour. + action = Some(if several > 0 { + CanvasAction::Colour(c) + } else { + CanvasAction::Edit(Box::new(Edit::StyleAnnotation { + page, + index, + color: Some(c), + opacity: None, + width: None, + endings: None, + })) + }); ui.close(); } }); @@ -92,10 +129,24 @@ pub(crate) fn context_menu( ui.close(); } ui.separator(); - // The engine's own check: a locked comment, or one with a locked reply, stays. - if ui.add_enabled(allowed && deletable(page, index), egui::Button::new(tl!("Delete"))).clicked() { - view.comments.selected = None; - action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page, index }))); + // The comments themselves, to paste into this or another document (⌘C / ⌘V too). + let copy_key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY); + if ui.add(egui::Button::new(tl!(if several > 0 { "Copy comments" } else { "Copy comment" })).shortcut_text(copy_key)).clicked() { + view.comments.copy_request = true; + ui.close(); + } + let paste_key = crate::commands::shortcut_label(ui.ctx(), crate::commands::PASTE); + if ui.add_enabled(allowed && can_paste, egui::Button::new(tl!("Paste comments")).shortcut_text(paste_key)).clicked() { + let at = view.comments.context_at.unwrap_or((page, [f64::from(a.rect[0]), f64::from(a.rect[1])])); + view.comments.paste_request = Some(at); + ui.close(); + } + ui.separator(); + // The engine's own check, for each selected comment: a locked comment, or one with a + // locked reply, stays. + let deletes = deletable(page, index) && view.comments.also.iter().all(|&(p, i)| deletable(p, i)); + if ui.add_enabled(allowed && deletes, egui::Button::new(tl!(if several > 0 { "Delete comments" } else { "Delete" }))).clicked() { + action = delete_selected(&mut view.comments).map(|e| CanvasAction::Edit(Box::new(e))); ui.close(); } if ui.add_enabled(allowed, egui::Button::new(tl!("Properties…"))).clicked() { @@ -140,6 +191,8 @@ pub enum CanvasAction { OpenComments, /// Comment Properties for (page, index). Properties(usize, usize), + /// Give every selected comment this colour. + Colour(Rgb), /// Copy Image: image (page, index in `page_images`) to the clipboard. CopyImage(usize, usize), /// Copy Image in Original Format: the same image as a file (JPEG as stored, else PNG). diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index f993ecf85b..c122bca275 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -2,7 +2,7 @@ use super::geometry::{HANDLES, handle_pos, held, is_markup, reaches_points, resh use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS}; use super::{ Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, - Stroke, color32, vec2, + Stroke, color32, pos2, vec2, }; use crate::page_router::{CommentPart, Hit, Owner}; @@ -38,6 +38,12 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside); } } + // The comments selected besides the one with the handles: outlined, not resizable. + for a in cv.also.iter().filter(|(p, _)| *p == cx.page).filter_map(|(_, i)| cx.get(*i)) { + for r in cx.screen_rects(a) { + painter.rect_stroke(r.expand(2.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle); + } + } if let Some((page, index)) = cv.selected && page == cx.page && let Some(a) = cx.get(index) @@ -93,6 +99,11 @@ pub(super) fn changing_points( }), (_, Some(Edit::ReshapeAnnotation { page: p, index: i, points: to })) if (*p, *i) == (page, index) => Some(to.clone()), (_, Some(Edit::MoveAnnotation { page: p, index: i, dx, dy })) if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)), + // Moved with the rest of the selection: its own move in the batch. + (_, Some(Edit::Batch { edits, .. })) => edits.iter().find_map(|e| match e { + Edit::MoveAnnotation { page: p, index: i, dx, dy } if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)), + _ => None, + }), _ => None, } } @@ -128,6 +139,82 @@ fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { { paint_draw(painter, cx, tool, points); } + // The bundle of copied comments, hanging on the pointer over this page: its box, and each + // comment's outline in its colour, so the drop can be aimed to the line. + if let Some(Gesture::Place { bounds, count, outlines, .. }) = &view.comments.gesture + && let Some(p) = cx.ptr.pos + && cx.xf.rect.contains(p) + { + let [x, y] = cx.to_user(p); + let (dx, dy) = (x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0); + let r = Rect::from_two_pos(cx.to_screen([bounds[0] + dx, bounds[1] + dy]), cx.to_screen([bounds[2] + dx, bounds[3] + dy])); + painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.6)), egui::StrokeKind::Middle); + let at = |q: [f64; 2]| cx.to_screen([q[0] + dx, q[1] + dy]); + for outline in outlines { + paint_outline(painter, outline, &at); + } + let label = crate::i18n::fmt(tl!("Comments: {n}"), &[("n", &count.to_string())]); + painter.text(r.left_top() + vec2(0.0, -4.0), egui::Align2::LEFT_BOTTOM, label, crate::theme::regular(11.0), SELECT_BLUE); + } +} + +/// One copied comment's outline on screen (`at` maps a user-space point, moved to where the +/// bundle hangs), in the comment's colour. +fn paint_outline(painter: &egui::Painter, outline: &pdfcraft_engine::CommentOutline, at: &dyn Fn([f64; 2]) -> Pos2) { + use pdfcraft_engine::CommentOutline as O; + let color = |c: &Option<[f64; 3]>| { + c.map(|[r, g, b]| Color32::from_rgb((r.clamp(0.0, 1.0) * 255.0) as u8, (g.clamp(0.0, 1.0) * 255.0) as u8, (b.clamp(0.0, 1.0) * 255.0) as u8)) + .unwrap_or(SELECT_BLUE) + }; + match outline { + O::Rect { rect, round, color: c } => { + let stroke = Stroke::new(1.5, color(c)); + let r = Rect::from_two_pos(at([rect[0], rect[1]]), at([rect[2], rect[3]])); + if *round { + let (cx, cy, rx, ry) = (r.center().x, r.center().y, r.width() / 2.0, r.height() / 2.0); + let points: Vec = + (0..48).map(|i| i as f32 / 48.0 * std::f32::consts::TAU).map(|t| pos2(cx + rx * t.cos(), cy + ry * t.sin())).collect(); + painter.add(egui::Shape::closed_line(points, stroke)); + } else { + painter.rect_stroke(r, CornerRadius::ZERO, stroke, egui::StrokeKind::Middle); + } + } + O::Line { points, color: c } => { + let stroke = Stroke::new(1.5, color(c)); + painter.add(egui::Shape::line(points.iter().map(|p| at(*p)).collect(), stroke)); + } + O::Poly { points, closed, color: c } => { + let stroke = Stroke::new(1.5, color(c)); + let points: Vec = points.iter().map(|p| at(*p)).collect(); + painter.add(if *closed { egui::Shape::closed_line(points, stroke) } else { egui::Shape::line(points, stroke) }); + } + O::Ink { strokes, color: c } => { + let stroke = Stroke::new(1.5, color(c)); + for s in strokes { + painter.add(egui::Shape::line(s.iter().map(|p| at(*p)).collect(), stroke)); + } + } + O::Quads { quads, color: c } => { + let fill = color(c).gamma_multiply(0.35); + for q in quads { + // Upper left, upper right, lower right, lower left: a closed shape in that order. + let points = vec![at([q[0], q[1]]), at([q[2], q[3]]), at([q[6], q[7]]), at([q[4], q[5]])]; + painter.add(egui::Shape::convex_polygon(points, fill, Stroke::NONE)); + } + } + O::Text { rect, text, size, color: c } => { + // At the page's zoom, wrapped in the box; too small to read at this zoom, left out. + let r = Rect::from_two_pos(at([rect[0], rect[1]]), at([rect[2], rect[3]])); + let zoom = at([1.0, 0.0]).distance(at([0.0, 0.0])); + let px = *size as f32 * zoom; + if !(px.is_finite() && px >= 3.0) { + return; + } + let pad = 2.0 * zoom; + let galley = painter.layout(text.clone(), egui::FontId::proportional(px.min(400.0)), color(c), (r.width() - 2.0 * pad).max(1.0)); + painter.with_clip_rect(r.intersect(painter.clip_rect())).galley(r.min + vec2(pad, pad), galley, color(c)); + } + } } /// A drawing tool's shape from its points (a live drag, or a released one still settling). diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 9c876d950f..cff7b1d8a6 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -29,6 +29,10 @@ pub enum Gesture { /// Dragging point `point` of a line, connected lines, a polygon or a cloud (its `points` /// before the drag, in user space) to where the pointer is. Reshape { page: usize, index: usize, point: usize, points: Vec<[f64; 2]> }, + /// Copied comments hanging on the pointer as one bundle (their box `bounds` in user space, + /// `count` of them, their `outlines` drawn along so the drop can be aimed): a click puts + /// their centre there, on the page clicked; Escape or a right-click lets them go. + Place { xfdf: String, bounds: [f64; 4], count: usize, outlines: Vec }, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -127,8 +131,21 @@ impl CommentView { /// Per-document commenting state. #[derive(Clone, Debug, Default)] pub struct CommentView { - /// The selected comment: (page, index in `/Annots`). + /// The selected comment: (page, index in `/Annots`). With several selected (`also`), the one + /// the handles, the properties and the context menu act on. pub selected: Option<(usize, usize)>, + /// The other selected comments (⌘/Ctrl-click or ⇧-click on the page adds one, ⇧-click in the + /// Comments panel a range): Delete, Copy, Properties and a drag act on them with `selected`. + pub also: Vec<(usize, usize)>, + /// Copy the selected comments (set by the menus; the app has the clipboard). + pub copy_request: bool, + /// Paste the copied comments onto this page, centred at this user-space point (set by the + /// page's menu, which knows where it was opened). + pub paste_request: Option<(usize, [f64; 2])>, + /// A notice for the app to show (the page input has no app). + pub notice: Option<&'static str>, + /// Finish the connected lines, polygon or cloud being drawn (the context menu's Complete). + pub finish_request: bool, pub gesture: Option, /// Drawings just released: shown from their points until the page raster that includes the /// committed annotation arrives, so the stroke never vanishes in between (#1858). @@ -187,6 +204,55 @@ pub(crate) enum EditorFocus { } impl CommentView { + /// Every selected comment, the one the handles belong to first. + pub fn selection(&self) -> Vec<(usize, usize)> { + let mut all: Vec<(usize, usize)> = self.selected.into_iter().collect(); + all.extend(self.also.iter().copied().filter(|k| Some(*k) != self.selected)); + all + } + + /// Whether `key` (page, index) is among the selected comments. + pub fn is_selected(&self, key: (usize, usize)) -> bool { + self.selected == Some(key) || self.also.contains(&key) + } + + /// Select `key` alone. + pub fn select_only(&mut self, key: (usize, usize)) { + self.selected = Some(key); + self.also.clear(); + } + + /// ⇧-click in a list (`order`, as shown): every comment from the one with the handles to + /// `key` is selected, and only those, so another ⇧-click from the same comment changes the + /// range. Without a comment with the handles, or with either end not in the list, `key` is + /// added as by ⌘/Ctrl-click. + pub fn select_range(&mut self, order: &[(usize, usize)], key: (usize, usize)) { + let ends = self.selected.and_then(|anchor| Some((order.iter().position(|k| *k == anchor)?, order.iter().position(|k| *k == key)?))); + let Some((a, b)) = ends else { return self.toggle(key) }; + let anchor = self.selected; + self.also = order.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().copied().filter(|k| Some(*k) != anchor).collect(); + } + + /// ⌘/Ctrl-click: add `key` to the selection, or take it out again. The handles go to the + /// comment clicked last. + pub fn toggle(&mut self, key: (usize, usize)) { + if self.selected == Some(key) { + self.selected = self.also.pop(); + } else if let Some(at) = self.also.iter().position(|k| *k == key) { + self.also.remove(at); + } else { + if let Some(previous) = self.selected.replace(key) { + self.also.push(previous); + } + } + } + + /// Nothing selected. + pub fn clear_selection(&mut self) { + self.selected = None; + self.also.clear(); + } + /// Takes back the last point of a polygon, cloud or connected lines being drawn; the first /// point's removal cancels the drawing. Returns whether there was such a drawing. pub fn undo_point(&mut self) -> bool { @@ -226,6 +292,7 @@ impl CommentView { /// went too. pub(crate) fn annotation_deleted(&mut self, before: &[Annotation], after: &[Annotation], gone: (usize, usize)) { self.selected = self.selected.and_then(|k| after_delete(before, after, gone, k)); + self.also = std::mem::take(&mut self.also).into_iter().filter_map(|k| after_delete(before, after, gone, k)).collect(); self.editing = self.editing.take().and_then(|(p, i, text)| after_delete(before, after, gone, (p, i)).map(|(p, i)| (p, i, text))); if self.editing.is_none() { self.editor_focus = None; @@ -237,7 +304,7 @@ impl CommentView { /// editor close, its unsaved text with it, rather than Save writing that text over whatever /// took its place. pub(crate) fn history_stepped(&mut self) { - self.selected = None; + self.clear_selection(); self.editing = None; self.editor_focus = None; } diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 0f9edd6c22..acb4008d3b 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -33,6 +33,13 @@ fn a_moved_or_reshaped_line_is_drawn_at_its_new_points() { let to = Some(pos2(60.0, 10.0)); assert_eq!(changing_points(Some(&dragging), None, (0, 3), &line, to_user, to, false), Some(vec![[10.0, 10.0], [60.0, 10.0]])); assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, false), None, "not changing"); + // Released with the rest of the selection: its own move in the batch. + let together = Edit::Batch { + label: "Move comments".into(), + edits: vec![Edit::MoveAnnotation { page: 0, index: 1, dx: 1.0, dy: 1.0 }, Edit::MoveAnnotation { page: 0, index: 3, dx: 5.0, dy: 20.0 }], + }; + assert_eq!(changing_points(None, Some(&together), (0, 3), &line, to_user, None, false), Some(vec![[15.0, 30.0], [55.0, 30.0]])); + assert_eq!(changing_points(None, Some(&together), (0, 2), &line, to_user, None, false), None, "not in the batch"); } /// A sticky note on a page turned a quarter is drawn upright beside its `/Rect`: an edit @@ -407,7 +414,8 @@ fn context_menu_colour_submenu_keeps_its_picker_open_and_a_swatch_closes_the_men let mut h = egui_kittest::Harness::new_ui_state( |ui, (view, actions): &mut (DocView, Vec)| { ui.button("Page").context_menu(|ui| { - if let Some(a) = context_menu(ui, view, &info, &|_, _| true, &prefs, true, true) { + let rights = MenuRights { allowed: true, can_paste: false, can_modify: true }; + if let Some(a) = context_menu(ui, view, &info, &|_, _| true, &prefs, rights) { actions.push(a); } }); @@ -490,6 +498,24 @@ fn drawn_shapes_ignore_slips() { )); } +#[test] +fn shift_click_selects_the_range_from_the_comment_with_the_handles() { + let order = [(0, 3), (0, 1), (0, 0), (1, 2), (1, 0)]; + let mut cv = CommentView::default(); + cv.select_only((0, 1)); + cv.select_range(&order, (1, 2)); + assert_eq!(cv.selection(), vec![(0, 1), (0, 0), (1, 2)], "from the anchor down, the anchor keeps the handles"); + // Another ⇧-click from the same anchor changes the range, upwards too. + cv.select_range(&order, (0, 3)); + assert_eq!(cv.selection(), vec![(0, 1), (0, 3)]); + // Nothing to start from, or a card not in the list: added as by ⌘/Ctrl-click. + let mut none = CommentView::default(); + none.select_range(&order, (0, 0)); + assert_eq!(none.selection(), vec![(0, 0)]); + cv.select_range(&order, (9, 9)); + assert_eq!(cv.selected, Some((9, 9))); +} + #[test] fn text_boxes_fit_their_text() { let r = text_box_rect([100.0, 500.0], "Hi", 12.0, 0); diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index a156dfe3d2..af64f63adf 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -494,7 +494,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bo // sees its lost focus, so replacing the box first would drop what was typed. A new box // deselects, as a comment tool's note or text box does: Escape then closes the box and // leaves the tool in two presses. - view.comments.selected = None; + view.comments.clear_selection(); let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, color: text_color, focus: true }); open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| { FillAction::Edit(Box::new(typed_with_color( diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index fffbd9d6d8..0ef5806ee3 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -578,7 +578,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { } if let Some((page, annotation, reading, scale)) = select { app.views[index].current = page; - app.views[index].comments.selected = Some((page, annotation)); + app.views[index].comments.select_only((page, annotation)); app.views[index].measure.reading = Some(reading); app.views[index].measure.scale = Some(scale); app.quick_tool = QuickTool::Select; diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index 04bee7d423..dd6e7178b0 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -100,6 +100,22 @@ fn every_registered_command_is_implemented() { if spec.id == "edit.redo" { app.undo(); } + // Copying comments needs a selected comment; pasting needs copied ones. + if matches!(spec.id, "comment.copy" | "comment.paste") { + let shape = pdfcraft_engine::Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }; + let style = pdfcraft_engine::Style::default_for(&shape); + assert!(app.apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: String::new(), + author: String::new(), + }))); + app.views[0].comments.select_only((0, 0)); + if spec.id == "comment.paste" { + assert!(app.copy_selected_comments(0)); + } + } if spec.id.starts_with("edit.") && (spec.id.ends_with(".update") || spec.id.ends_with(".remove")) { use pdfcraft_engine::{Background, Edit, HeaderFooter, Watermark}; let mut hf = HeaderFooter::default(); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 444db23c84..cff01c0014 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -84,6 +84,19 @@ fn click_pt(h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32)) { let p = at(h, p.0, p.1); click(h, p); } +/// Click a page point with modifier keys held (⌘-click adds a comment to the selection). +fn click_pt_with(h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32), modifiers: egui::Modifiers) { + let p = at(h, p.0, p.1); + h.hover_at(p); + h.event(egui::Event::ModifiersChanged(modifiers)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(2); +} /// A text field by its placeholder ("Add a comment", "Add a reply"). fn field<'a>(h: &'a Harness<'static, PdfCraftApp>, placeholder: &'a str) -> egui_kittest::Node<'a> { @@ -3716,3 +3729,442 @@ fn other_edit_composers_keep_trimming_trailing_whitespace() { edit_composer_preserves_only_typewriter_whitespace(kind); } } + +/// ⌘-click (Ctrl on Linux and Windows) adds comments to the selection and takes one out again; +/// Delete removes every selected comment as one undo step. +#[test] +fn ctrl_click_selects_several_comments_and_delete_removes_them_all() { + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + for (a, b) in [((20.0, 20.0), (60.0, 60.0)), ((100.0, 20.0), (140.0, 60.0)), ((180.0, 20.0), (220.0, 60.0))] { + drag_pt(&mut h, a, b); + } + assert_eq!(comments(&h).len(), 3); + h.state_mut().set_option("quick", "select").unwrap(); + h.run_steps(2); + click_pt(&mut h, (40.0, 40.0)); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 0)]); + click_pt_with(&mut h, (120.0, 40.0), egui::Modifiers::COMMAND); + click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::COMMAND); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 2), (0, 0), (0, 1)], "the handles go to the one clicked last"); + assert_eq!(h.state().views[0].comments.selected, Some((0, 2))); + // ⌘-click on a selected comment takes it out again. + click_pt_with(&mut h, (40.0, 40.0), egui::Modifiers::COMMAND); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 2), (0, 1)]); + // Delete: both go, as one step. + h.key_press(egui::Key::Delete); + h.run_steps(3); + let left = comments(&h); + assert_eq!(left.len(), 1, "{left:?}"); + assert!((left[0].rect[0] - 20.0).abs() < 1.0, "the unselected first rectangle stays: {:?}", left[0].rect); + assert!(h.state().views[0].comments.selection().is_empty()); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Delete comments")); + h.state_mut().undo(); + assert_eq!(comments(&h).len(), 3); + // A plain click selects one alone again. + click_pt(&mut h, (120.0, 40.0)); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 1)]); + // On the page ⇧-click adds one too, and takes it out again (a range is the panel's). + click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::SHIFT); + assert_eq!(h.state().views[0].comments.selection().len(), 2, "{:?}", h.state().views[0].comments.selection()); + click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::SHIFT); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 1)]); +} + +/// Dragging one of several selected comments moves them all, whichever of them is grabbed: the +/// grabbed one takes the handles and the one that had them stays selected. +#[test] +fn dragging_any_selected_comment_moves_the_whole_selection() { + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + for (a, b) in [((20.0, 20.0), (60.0, 60.0)), ((100.0, 20.0), (140.0, 60.0)), ((180.0, 20.0), (220.0, 60.0))] { + drag_pt(&mut h, a, b); + } + h.state_mut().set_option("quick", "select").unwrap(); + h.run_steps(2); + click_pt(&mut h, (40.0, 40.0)); + click_pt_with(&mut h, (120.0, 40.0), egui::Modifiers::COMMAND); + click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::COMMAND); + assert_eq!(h.state().views[0].comments.selected, Some((0, 2)), "the last one clicked has the handles"); + // Drag the first one (not the one with the handles) 30 points up. + let before = comments(&h); + drag_pt(&mut h, (40.0, 40.0), (40.0, 70.0)); + let after = comments(&h); + assert_eq!(after.len(), 3); + for (a, b) in before.iter().zip(&after) { + assert!((b.rect[1] - a.rect[1] - 30.0).abs() < 1.5, "every selected rectangle moved up 30 pt: {:?} from {:?}", b.rect, a.rect); + } + let selection = h.state().views[0].comments.selection(); + assert_eq!(selection.len(), 3, "all three stay selected: {selection:?}"); + assert_eq!(selection[0], (0, 0), "the grabbed one has the handles"); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comments")); + // A locked one in the selection stays where it is; the others still move (it would refuse + // the whole step otherwise, and nothing would move). + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::LockAnnotation { page: 0, index: 1, locked: true })); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.selection().len(), 3); + let before = comments(&h); + drag_pt(&mut h, (40.0, 70.0), (40.0, 90.0)); + let after = comments(&h); + // By index: the list isn't kept in index order. + let bottom = |list: &[Annotation], i: usize| list.iter().find(|a| a.index == i).map(|a| a.rect[1]).expect("still there"); + for i in 0..3 { + let moved = bottom(&after, i) - bottom(&before, i); + if i == 1 { + assert!(moved.abs() < 0.01, "the locked one stays: moved {moved}"); + } else { + assert!((moved - 20.0).abs() < 1.5, "rectangle {i} moved up 20 pt: moved {moved}"); + } + } + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comments")); +} + +/// Copied comments on the pointer take the click whatever tool is on: with Fill & Sign's Text +/// tool or a stamp tool still on, the click places the bundle and opens no text box or stamp. +#[test] +fn copied_comments_take_the_click_from_the_tool_that_is_on() { + use pdfcraft_ui_egui::fill_sign::FillTool; + let mut h = harness(|_| {}); + let id = h.state().views[0].id; + let shape = pdfcraft_engine::Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] }; + let style = pdfcraft_engine::Style::default_for(&shape); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: String::new(), + author: "Tester".into(), + }))); + h.run_steps(2); + h.state_mut().views[0].comments.select_only((0, 0)); + assert!(h.state_mut().execute("comment.copy")); + let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.annotations.len(); + for (tool, at) in [(QuickTool::Fill(FillTool::Text), (150.0, 100.0)), (QuickTool::Stamp(pdfcraft_engine::StampKind::Approved), (200.0, 160.0))] { + h.state_mut().quick_tool = tool; + h.run_steps(2); + let before = count(&h); + assert!(h.state_mut().execute("comment.paste")); + click_pt(&mut h, at); + assert_eq!(count(&h), before + 1, "{tool:?}: the copy, and nothing else"); + assert!(h.state().views[0].fill_text.is_none(), "{tool:?}: no text box opens"); + assert!(h.state().views[0].comments.gesture.is_none()); + let placed = h.state().session.get(id).unwrap().info.annotations.iter().max_by_key(|a| a.index).map(|a| a.rect).expect("placed"); + assert!(((placed[0] + placed[2]) / 2.0 - at.0).abs() < 1.0, "{tool:?}: centred on the click: {placed:?}"); + assert_eq!(h.state().quick_tool, tool, "the tool stays on for the next click"); + } + // Afterwards the tool takes its clicks again. + h.state_mut().quick_tool = QuickTool::Fill(FillTool::Text); + h.run_steps(2); + click_pt(&mut h, (100.0, 200.0)); + assert!(h.state().views[0].fill_text.is_some(), "Fill & Sign's Text tool opens its box again"); +} + +/// Two boxes on page 1 of the active document, and a reply to the first. +fn add_two_boxes_and_a_reply(h: &mut Harness<'static, PdfCraftApp>) { + for rect in [[20.0, 20.0, 60.0, 60.0], [100.0, 20.0, 140.0, 60.0]] { + let shape = pdfcraft_engine::Shape::Rectangle { rect }; + let style = pdfcraft_engine::Style::default_for(&shape); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: "Box".into(), + author: "Tester".into(), + }))); + } + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::ReplyToAnnotation { page: 0, index: 0, text: "Reply".into(), author: "Tester".into() })); + h.run_steps(2); +} + +/// Copied comments paste into another document on its current page, each with its replies, as +/// new comments (a second paste gives a second set); the copies are selected afterwards. +#[test] +fn copied_comments_paste_into_another_document() { + let mut h = harness(|app| { + app.open_bytes("other.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + app.active = Some(0); + }); + let (source, target) = (h.state().views[0].id, h.state().views[1].id); + add_two_boxes_and_a_reply(&mut h); + // Adding a comment selects it; with nothing selected there is nothing to copy. + h.state_mut().views[0].comments.clear_selection(); + assert!(!h.state_mut().execute("comment.copy"), "nothing selected: nothing to copy"); + { + let cv = &mut h.state_mut().views[0].comments; + cv.select_only((0, 0)); + cv.also.push((0, 1)); + } + assert!(h.state_mut().execute("comment.copy")); + assert_eq!(h.state().comment_clipboard.as_ref().map(|c| c.count), Some(2)); + // Into the other document: Edit ▸ Paste comments hangs the bundle on the pointer, a click + // places it, centred there (the two boxes span 20..140 × 20..60: centre 80, 40). + h.state_mut().active = Some(1); + h.run_steps(2); + assert!(h.state_mut().execute("comment.paste")); + let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(target).unwrap().info.annotations.len(); + assert_eq!(count(&h), 0, "nothing placed yet"); + assert!(matches!(h.state().views[1].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Place { count: 2, .. }))); + click_pt(&mut h, (150.0, 100.0)); + let pasted = h.state().session.get(target).unwrap().info.annotations.clone(); + assert_eq!(pasted.len(), 3, "two comments and the reply: {pasted:?}"); + assert!(pasted.iter().all(|a| a.page == 0), "on the page clicked: {pasted:?}"); + let parent = pasted.iter().find(|a| a.in_reply_to.is_none() && (a.rect[0] - 90.0).abs() < 1.0).expect("the first box, moved by (70, 60)"); + assert!((parent.rect[1] - 80.0).abs() < 1.0 && (parent.rect[2] - 130.0).abs() < 1.0, "{:?}", parent.rect); + assert_eq!(pasted.iter().find(|a| a.in_reply_to.is_some()).and_then(|r| r.in_reply_to.clone()), parent.name, "the reply follows its copy"); + assert_eq!(h.state().views[1].comments.selection().len(), 2, "the copies are selected"); + assert!(h.state().views[1].comments.gesture.is_none()); + assert_eq!(h.state().session.get(target).unwrap().can_undo(), Some("Paste comments")); + assert_eq!(h.state().session.get(source).unwrap().info.annotations.len(), 3, "the source is untouched"); + // The page menu's Paste comments places the bundle where the menu was opened: page 2 here. + h.state_mut().views[1].comments.paste_request = Some((1, [100.0, 100.0])); + h.run_steps(3); + assert_eq!(count(&h), 6); + let second: Vec<_> = + h.state().session.get(target).unwrap().info.annotations.iter().filter(|a| a.page == 1 && a.in_reply_to.is_none()).cloned().collect(); + assert_eq!(second.len(), 2); + let centre_x = (second.iter().map(|a| a.rect[0]).fold(f32::MAX, f32::min) + second.iter().map(|a| a.rect[2]).fold(f32::MIN, f32::max)) / 2.0; + assert!((centre_x - 100.0).abs() < 1.0, "centred at the menu's point: {second:?}"); + // ⌘V (a Paste event) hangs the bundle on the pointer again; Escape lets it go. + h.event(egui::Event::Paste(String::new())); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_some()); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_none()); + assert_eq!(count(&h), 6, "nothing placed"); + h.state_mut().undo(); + assert_eq!(count(&h), 3); + // XFDF arriving from the system clipboard pastes too, even without the app's clipboard. + let xfdf = h.state().comment_clipboard.clone().unwrap().xfdf; + h.state_mut().comment_clipboard = None; + assert!(!h.state_mut().execute("comment.paste"), "nothing copied: disabled"); + h.event(egui::Event::Paste(xfdf.clone())); + h.run_steps(3); + click_pt(&mut h, (200.0, 120.0)); + assert_eq!(count(&h), 6); + h.event(egui::Event::Paste("just text".into())); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_none(), "plain text is not a paste of comments"); + // Nor when comments were copied earlier: a word copied in another app since is that word. + h.state_mut().comment_clipboard = Some(pdfcraft_ui_egui::CommentClip { xfdf, count: 2 }); + h.event(egui::Event::Paste("a word from elsewhere".into())); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_none(), "the old comments don't hang on the pointer"); + // Nothing legible on the system clipboard: the app's own copied comments. + h.event(egui::Event::Paste(String::new())); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_some()); +} + +/// Connected lines (and polygons) finish from the right-click menu too, as other editors do; +/// the menu also takes the last point back and cancels the drawing. +#[test] +fn connected_lines_are_completed_from_the_context_menu() { + let mut h = harness(|app| app.set_option("quick", "polyline").unwrap()); + for p in [(20.0, 150.0), (60.0, 130.0), (100.0, 150.0)] { + click_pt(&mut h, p); + } + assert_eq!(comments(&h).len(), 0, "still drawing"); + let right_click = |h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32)| { + let p = at(h, p.0, p.1); + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + }; + right_click(&mut h, (120.0, 100.0)); + h.get_by_label("Complete").click(); + h.run_steps(3); + let c = comments(&h); + assert_eq!(c.len(), 1, "{c:?}"); + assert_eq!(c[0].subtype, "PolyLine"); + // A polygon needs three points: with two, Complete is greyed; Undo last point and Cancel drawing work. + h.state_mut().set_option("quick", "polygon").unwrap(); + for p in [(150.0, 150.0), (200.0, 150.0)] { + click_pt(&mut h, p); + } + assert_eq!(drawn_points(&h), 2); + right_click(&mut h, (220.0, 100.0)); + assert!(h.get_by_label("Complete").accesskit_node().is_disabled()); + h.get_by_label("Undo last point").click(); + h.run_steps(3); + assert_eq!(drawn_points(&h), 1); + right_click(&mut h, (220.0, 100.0)); + h.get_by_label("Cancel drawing").click(); + h.run_steps(3); + assert_eq!(drawn_points(&h), 0); + assert!(h.state().views[0].comments.gesture.is_none()); + assert_eq!(comments(&h).len(), 1, "nothing else was drawn"); + // On the second page, with the first on screen in the same frame: Complete reaches the page + // the drawing is on (the first page passes first and must not take the request). + h.state_mut().set_option("quick", "polyline").unwrap(); + h.state_mut().set_option("zoom", "50").unwrap(); + crate::support::settle(&mut h); + assert!(h.state().views[0].page_screen_rect(0).is_some(), "page 1 is on screen too"); + let at2 = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| { + let r = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height()) + }; + for (x, y) in [(20.0, 100.0), (60.0, 80.0)] { + let p = at2(&h, x, y); + click(&mut h, p); + } + assert_eq!(drawn_points(&h), 2); + let p = at2(&h, 150.0, 150.0); + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + h.get_by_label("Complete").click(); + h.run_steps(3); + let c = comments(&h); + assert_eq!(c.len(), 2, "{c:?}"); + assert!(c.iter().any(|a| a.page == 1 && a.subtype == "PolyLine"), "{c:?}"); + assert!(h.state().views[0].comments.gesture.is_none()); +} + +/// Properties on one of several selected comments changes all of them, each where it applies: the +/// colour goes to every one, the line width to the shapes, the font size to the text box alone; +/// a locked one stays as it is. One undo step. Colouring the selection works the same way. +#[test] +fn properties_change_every_selected_comment_where_it_applies() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + let mut h = harness(|_| {}); + let shapes = [ + Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] }, + Shape::Oval { rect: [80.0, 20.0, 120.0, 60.0] }, + Shape::TextBox { rect: [140.0, 20.0, 200.0, 40.0], font_size: 12.0 }, + Shape::Rectangle { rect: [220.0, 20.0, 260.0, 60.0] }, + ]; + for shape in shapes { + let style = Style::default_for(&shape); + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "x".into(), + author: "Tester".into() + }))); + } + assert!(h.state_mut().apply_edit(Edit::LockAnnotation { page: 0, index: 3, locked: true })); + { + let c = &mut h.state_mut().views[0].comments; + c.selected = Some((0, 0)); + c.also = vec![(0, 1), (0, 2), (0, 3)]; + } + h.run_steps(2); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + h.get_by_label("Changes here apply to all 4 selected comments"); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + assert_eq!(d.others.len(), 3); + d.edited.color = Some([0.0, 0.0, 1.0]); + d.edited.width = Some(4.0); + } + h.get_by_label("OK").click(); + h.run_steps(3); + let props = |h: &Harness<'static, PdfCraftApp>, index: usize| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap() + }; + for index in [0, 1] { + let p = props(&h, index); + assert_eq!((p.color, p.width), (Some([0.0, 0.0, 1.0]), Some(4.0)), "shape {index}"); + } + assert_eq!(props(&h, 2).color, Some([0.0, 0.0, 1.0]), "the text box's text colour"); + let locked = props(&h, 3); + assert_ne!(locked.color, Some([0.0, 0.0, 1.0]), "the locked rectangle stays as it was"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Change comment properties"), "one undo step"); + // From the text box: its font size changes, and the shapes, which have none, keep their look. + h.state_mut().views[0].comments.selected = Some((0, 2)); + h.state_mut().views[0].comments.also = vec![(0, 0), (0, 1)]; + h.state_mut().open_comment_props(0, 2); + h.run_steps(2); + h.state_mut().comment_props.as_mut().expect("open").edited.font_size = Some(20.0); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(props(&h, 2).font_size, Some(20.0)); + assert_eq!((props(&h, 0).font_size, props(&h, 0).width), (None, Some(4.0))); + // Colouring the selection (the context menu's Colour with several selected). + h.state_mut().colour_selected_comments([1.0, 0.0, 0.0]); + h.run_steps(2); + for index in [0, 1, 2] { + assert_eq!(props(&h, index).color, Some([1.0, 0.0, 0.0]), "comment {index}"); + } + assert_ne!(props(&h, 3).color, Some([1.0, 0.0, 0.0])); +} + +/// Comment Properties' text opacity, set with several comments selected, goes to the other text +/// boxes whose text shows an opacity of its own (a border or a fill), and leaves a text-only box, +/// whose one opacity is the whole comment's, as it was. +#[test] +fn part_opacity_changes_every_selected_comment_that_shows_the_part() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + let mut h = harness(|_| {}); + for (n, width) in [1.0, 1.0, 0.0].into_iter().enumerate() { + let x = 20.0 + 100.0 * n as f64; + let shape = Shape::TextBox { rect: [x, 20.0, x + 80.0, 40.0], font_size: 12.0 }; + let style = Style { width, ..Style::default_for(&shape) }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "x".into(), + author: "Tester".into() + }))); + } + let props = |h: &Harness<'static, PdfCraftApp>, index: usize| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap() + }; + let text_only = props(&h, 2); + { + let c = &mut h.state_mut().views[0].comments; + c.selected = Some((0, 0)); + c.also = vec![(0, 1), (0, 2)]; + } + h.run_steps(2); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + h.state_mut().comment_props.as_mut().expect("open").edited.text_opacity = Some(0.4); + h.get_by_label("OK").click(); + h.run_steps(3); + for index in [0, 1] { + let text = props(&h, index).text_opacity.expect("a text box's text opacity"); + assert!((text - 0.4).abs() < 1e-3, "bordered box {index}: {text}"); + } + let after = props(&h, 2); + assert_eq!((after.text_opacity, after.opacity), (text_only.text_opacity, text_only.opacity), "the text-only box keeps its look"); +} + +/// Escape with a Fill & Sign tool on and several comments selected deselects them all first, +/// then drops the tool. +#[test] +fn escape_with_a_fill_tool_deselects_every_comment_then_drops_the_tool() { + use pdfcraft_ui_egui::fill_sign::FillTool; + let mut h = harness(|_| {}); + add_two_boxes_and_a_reply(&mut h); + h.state_mut().quick_tool = QuickTool::Fill(FillTool::Text); + { + let c = &mut h.state_mut().views[0].comments; + c.selected = Some((0, 0)); + c.also = vec![(0, 1)]; + } + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(2); + let c = &h.state().views[0].comments; + assert!(c.selected.is_none() && c.also.is_empty(), "the whole selection goes"); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Text), "the tool stays for now"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Select); +} diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 16c554d72f..c42d414918 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -29,9 +29,9 @@ pub use fdf::export_fdf; use fdf::{ImportBudget, fdf_body, import_fdf, walk_fdf}; #[cfg(test)] use fdf::{MAX_FDF_BYTES, MAX_FDF_OBJECTS}; -pub use xfdf::export_xfdf; #[cfg(test)] use xfdf::{LOCKED, find_by_name}; +pub use xfdf::{Outline, comment_count, comment_outlines, comments_bounds, export_xfdf, export_xfdf_comments, import_comments, is_xfdf_comments}; use xfdf::{apply_values, import_xfdf}; #[cfg(test)] diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 52dbe0369b..96e9179faa 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -4,6 +4,7 @@ use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, TextReplacement, use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_forms::{NewField, add_field, fields, set_value}; +use super::xfdf::PDFCRAFT_NS; use super::*; /// Two blank pages with three fields (text, check box, list). @@ -2099,3 +2100,323 @@ fn an_fdf_comment_popup_without_a_rect_is_dropped() { let popups = page.iter().filter(|a| dst.resolve(a).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Popup".as_slice()))).count(); assert_eq!(popups, 0); } + +/// Copy: the chosen comments travel with their replies and nothing else. +#[test] +fn exporting_selected_comments_takes_them_and_their_replies() { + let src = filled(); + let note = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap(); + let xfdf = export_xfdf_comments(&src, &[(note.page, note.index)], "form.pdf"); + assert!(xfdf.contains(""), "no field values"); + assert!(is_xfdf_comments(&xfdf)); + assert!(!is_xfdf_comments("") && !is_xfdf_comments("just text")); + // A position that is no comment exports nothing but a valid, empty file. + let empty = export_xfdf_comments(&src, &[(7, 7)], "form.pdf"); + assert!(empty.contains("\n"), "{empty}"); +} + +/// Paste: the copies land on the asked-for page under names of their own, so nothing is +/// replaced and a second paste gives a second set; the reply keeps its copied parent. +#[test] +fn importing_comments_as_copies_adds_them_on_the_page_with_fresh_names() { + let src = filled(); + let note = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap(); + let xfdf = export_xfdf_comments(&src, &[(note.page, note.index)], "form.pdf"); + let mut dst = blank(); + let r = import_comments(&mut dst, &xfdf, Some(1), Some("paste1"), None).unwrap(); + assert_eq!((r.comments, r.fields), (2, 0), "{r:?}"); + let first: Vec<_> = summaries(&dst); + assert!(first.iter().all(|s| s.page == 1), "every copy is on page 2: {first:?}"); + let parent = first.iter().find(|s| s.subtype == "Text").unwrap(); + let reply = first.iter().find(|s| s.in_reply_to.is_some()).expect("the reply came along"); + assert_eq!(reply.in_reply_to.as_deref(), Some("paste1-1"), "and replies to the copied note, by its new name ({parent:?})"); + // Again: two sets, not one replaced set. + import_comments(&mut dst, &xfdf, Some(0), Some("paste2"), None).unwrap(); + let second = summaries(&dst); + assert_eq!(second.len(), 4, "{second:?}"); + assert_eq!(second.iter().filter(|s| s.page == 0).count(), 2); + // The copies' names are their own, not the originals'. + let xfdf2 = export_xfdf(&dst, true, false, "dst.pdf"); + assert!(!xfdf2.contains("name=\"n1\"") && !xfdf2.contains("name=\"r1\""), "{xfdf2}"); + assert!(xfdf2.contains("name=\"paste1-1\"") && xfdf2.contains("inreplyto=\"paste1-1\""), "{xfdf2}"); + // A page the document doesn't have is refused, and nothing changes. + assert!(import_comments(&mut dst, &xfdf, Some(9), Some("paste3"), None).is_err()); + assert_eq!(summaries(&dst).len(), 4); + // Without a target page the copies keep their own pages; without a prefix the import is + // the usual one (same names replace). + let mut other = blank(); + import_comments(&mut other, &xfdf, None, Some("p"), None).unwrap(); + assert!(summaries(&other).iter().all(|s| s.page == 0)); + import_comments(&mut other, &xfdf, None, None, None).unwrap(); + import_comments(&mut other, &xfdf, None, None, None).unwrap(); + assert_eq!(summaries(&other).len(), 4, "the named import replaced its own earlier copy"); +} + +/// Other tools write comments straight into the page's `/Annots` array, without an object of +/// their own: they copy like any other, and two source comments sharing a name give two copies. +#[test] +fn comments_written_directly_into_the_page_array_copy_and_shared_names_give_two_copies() { + let mut doc = blank(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let mut direct = Dict::new(); + direct.set(b"Type".to_vec(), Object::name("Annot")); + direct.set(b"Subtype".to_vec(), Object::name("Square")); + direct.set(b"Rect".to_vec(), arr(&[10.0, 10.0, 60.0, 40.0])); + direct.set(b"Contents".to_vec(), PdfString::text("drawn elsewhere")); + let mut twin = direct.clone(); + twin.set(b"NM".to_vec(), PdfString::text("shared")); + let mut other_twin = twin.clone(); + other_twin.set(b"Rect".to_vec(), arr(&[100.0, 10.0, 150.0, 40.0])); + doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(direct), Object::Dict(twin), Object::Dict(other_twin)]))) + .unwrap(); + assert_eq!(summaries(&doc).len(), 3); + let xfdf = export_xfdf_comments(&doc, &[(0, 0)], "x.pdf"); + assert!(xfdf.contains("\n"), "a position that is no annotation copies nothing"); +} + +/// A selected comment without a name of its own (other tools leave /NM out) gets one for the +/// export, so its replies still find it when the copies are pasted. +#[test] +fn replies_to_a_selected_comment_without_a_name_follow_it_into_the_copy() { + let meta = |id: &str| Meta { date: None, id: id.into() }; + let mut doc = blank(); + let shape = Shape::Rectangle { rect: [10.0, 10.0, 60.0, 40.0] }; + add_annotation( + &mut doc, + &NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: "nameless".into(), author: "Ada".into() }, + &meta("gone"), + ) + .unwrap(); + let index = summaries(&doc)[0].index; + add_reply(&mut doc, 0, index, "Agreed", "Bob", &meta("r1")).unwrap(); + // The parent loses its /NM, as another tool may have left it. + let annots = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap(); + let parent = annots[index].as_ref().unwrap(); + doc.update_dict(parent, |d| { + d.remove(b"NM"); + }) + .unwrap(); + assert_eq!(threads(&doc)[0].0, None, "{:?}", threads(&doc)); + // A comment written straight into the page array already carries the name the parent would + // get: the parent's export name steers clear of it. + let mut direct = Dict::new(); + direct.set(b"Type".to_vec(), Object::name("Annot")); + direct.set(b"Subtype".to_vec(), Object::name("Square")); + direct.set(b"Rect".to_vec(), arr(&[100.0, 100.0, 150.0, 140.0])); + direct.set(b"NM".to_vec(), PdfString::text(&format!("pdfcraft-0-{index}"))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |p| { + if let Some(Object::Array(annots)) = p.get_mut(b"Annots") { + annots.push(Object::Dict(direct)); + } + }) + .unwrap(); + let xfdf = export_xfdf_comments(&doc, &[(0, index)], "x.pdf"); + let given = format!("pdfcraft-0-{index}x"); + assert!(xfdf.contains(&format!("name=\"{given}\"")) && xfdf.contains(&format!("inreplyto=\"{given}\"")), "{xfdf}"); + let mut dst = blank(); + import_comments(&mut dst, &xfdf, Some(0), Some("p"), None).unwrap(); + let copies = threads(&dst); + assert_eq!(copies.len(), 2, "{copies:?}"); + let parent_name = copies.iter().find(|(_, irt, _)| irt.is_none()).and_then(|(n, _, _)| n.clone()); + assert!(parent_name.is_some(), "{copies:?}"); + let reply_to = copies.iter().find(|(_, irt, _)| irt.is_some()).and_then(|(_, irt, _)| irt.clone()); + assert_eq!(reply_to, parent_name, "the reply follows its copied parent: {copies:?}"); +} + +/// A paste placed elsewhere: every copy moves by the offset, rect, quads, ink and vertices +/// alike; the bounds and count of a clipboard text say what hangs on the pointer. +#[test] +fn copies_placed_with_an_offset_move_whole_and_the_text_reports_its_bounds() { + let src = filled(); + let all: Vec<(usize, usize)> = summaries(&src).iter().filter(|s| s.in_reply_to.is_none()).map(|s| (s.page, s.index)).collect(); + let xfdf = export_xfdf_comments(&src, &all, "form.pdf"); + assert_eq!(comment_count(&xfdf), 3, "note, highlight, ink (the reply is not counted)"); + let bounds = comments_bounds(&xfdf).unwrap(); + assert!(bounds[0] <= 10.0 && bounds[1] <= 10.0 && bounds[2] >= 100.0 && bounds[3] >= 500.0, "{bounds:?}"); + assert_eq!(comment_count("just text"), 0); + assert_eq!(comments_bounds(""), None); + // A reply with a rect of its own (some tools write one, at 0,0 or away from its parent) + // doesn't widen the box the paste is centred on: it has no place on the page. + let with_reply = r#" + + re + "#; + assert_eq!(comments_bounds(with_reply), Some([100.0, 100.0, 140.0, 120.0])); + let mut dst = blank(); + import_comments(&mut dst, &xfdf, Some(0), Some("p"), Some([100.0, -50.0])).unwrap(); + let moved = summaries(&dst); + let note = moved.iter().find(|s| s.subtype == "Text").unwrap(); + let original = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap(); + let (a, b) = (note.rect, original.rect); + assert!((a[0] - b[0] - 100.0).abs() < 0.01 && (a[1] - b[1] + 50.0).abs() < 0.01, "{a:?} from {b:?}"); + let again = export_xfdf(&dst, true, false, "dst.pdf"); + assert!(again.contains("coords=\"110,60,190,60,110,50,190,50\""), "quads moved too: {again}"); + assert!(again.contains("110,-40;120,-20;140,-30"), "ink moved too: {again}"); + // The outlines say what hangs on the pointer: a box for the note, quads for the highlight, + // strokes for the ink, each in its colour; the reply draws nothing of its own. + let outlines = comment_outlines(&xfdf); + assert_eq!(outlines.len(), 3, "{outlines:?}"); + assert!(outlines.iter().any(|o| matches!(o, Outline::Rect { round: false, .. }))); + assert!(outlines.iter().any(|o| matches!(o, Outline::Quads { quads, .. } if quads.len() == 1 && quads[0][0] == 10.0))); + assert!(outlines.iter().any(|o| matches!(o, Outline::Ink { strokes, .. } if strokes == &vec![vec![[10.0, 10.0], [20.0, 30.0], [40.0, 20.0]]]))); + assert!(comment_outlines("no xfdf").is_empty()); + let drawn = "\ + \ + 1,1;5,5;9,1\ + 1,1;5,5;9,1\ + \ + \ + "; + let outlines = comment_outlines(drawn); + assert_eq!(outlines.len(), 6, "{outlines:?}"); + assert_eq!(outlines[0], Outline::Line { points: vec![[1.0, 2.0], [3.0, 4.0]], color: Some([1.0, 0.0, 0.0]) }); + assert!(matches!(&outlines[1], Outline::Poly { closed: false, points, .. } if points.len() == 3)); + assert!(matches!(&outlines[2], Outline::Poly { closed: true, .. })); + assert!(matches!(&outlines[3], Outline::Rect { round: true, .. })); + assert!(matches!(&outlines[5], Outline::Line { points, .. } if points == &vec![[20.0, 20.0], [40.0, 50.0]])); + // A non-finite offset is ignored rather than written into the document. + import_comments(&mut dst, &xfdf, Some(1), Some("q"), Some([f64::NAN, 0.0])).unwrap(); + assert_eq!(summaries(&dst).iter().filter(|s| s.page == 1 && s.subtype == "Text").map(|s| s.rect[0]).next(), Some(b[0])); +} + +/// Fill & Sign marks and text boxes copied: the bundle on the pointer shows the tick, cross, dot +/// and line themselves (not empty boxes) and a text box's text, and the pasted copies are drawn +/// with their mark. +#[test] +fn copied_marks_and_text_boxes_show_what_they_are_while_placed_and_once_pasted() { + use pdfcraft_annot::FillMark; + let mut src = blank(); + let meta = Meta { date: Some("D:20261020120000Z".into()), id: "m".into() }; + let marks = [FillMark::Check, FillMark::Cross, FillMark::Dot, FillMark::Line]; + for (k, mark) in marks.into_iter().enumerate() { + let shape = Shape::Mark { rect: [100.0 + 40.0 * k as f64, 600.0, 120.0 + 40.0 * k as f64, 620.0], mark }; + let new = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() }; + add_annotation(&mut src, &new, &meta).unwrap(); + } + let text = Shape::TextBox { rect: [100.0, 400.0, 200.0, 430.0], font_size: 14.0 }; + let new = NewAnnotation { page: 0, style: Style::default_for(&text), shape: text, contents: "Abnahme\nok".into(), author: "Ada".into() }; + add_annotation(&mut src, &new, &meta).unwrap(); + let all: Vec<(usize, usize)> = summaries(&src).iter().filter(|s| s.in_reply_to.is_none()).map(|s| (s.page, s.index)).collect(); + let xfdf = export_xfdf_comments(&src, &all, "protocol.pdf"); + let outlines = comment_outlines(&xfdf); + // The tick is one stroke, the cross two, the dot a circle, the line one stroke: the strokes + // the appearance draws, not the stamps' boxes. + let check = FillMark::Check.strokes([100.0, 600.0, 120.0, 620.0]); + assert!(outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if *points == check[0])), "{outlines:?}"); + assert!(!outlines.iter().any(|o| matches!(o, Outline::Rect { rect, round: false, .. } if *rect == [100.0, 600.0, 120.0, 620.0])), "{outlines:?}"); + let cross = FillMark::Cross.strokes([140.0, 600.0, 160.0, 620.0]); + assert!(cross.iter().all(|s| outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if points == s))), "{outlines:?}"); + assert!( + outlines + .iter() + .any(|o| matches!(o, Outline::Rect { rect, round: true, .. } if (rect[0] - 184.0).abs() < 1e-9 && (rect[2] - 196.0).abs() < 1e-9)), + "{outlines:?}" + ); + assert!(outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if *points == vec![[220.0, 610.0], [240.0, 610.0]])), "{outlines:?}"); + assert!( + outlines.iter().any(|o| matches!(o, Outline::Text { text, size, .. } if text == "Abnahme\nok" && (*size - 14.0).abs() < 1e-9)), + "the text box shows its text at its size: {outlines:?}" + ); + // Pasted into another document, each mark is drawn with its own strokes. + let mut dst = blank(); + import_comments(&mut dst, &xfdf, Some(1), Some("p"), Some([10.0, -20.0])).unwrap(); + let page = pdfcraft_model::pages(&dst)[1].obj; + let annots = + dst.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| dst.resolve(&a).as_array().cloned().unwrap_or_default()).unwrap(); + let stamps: Vec = + annots.iter().filter_map(|a| dst.resolve(a).as_dict().cloned()).filter(|d| d.name(b"Subtype") == Some(b"Stamp".as_slice())).collect(); + assert_eq!(stamps.len(), 4); + for d in &stamps { + let ap = d.get(b"AP").map(|a| dst.resolve(a)).and_then(|a| a.as_dict().cloned()).and_then(|a| a.get(b"N").cloned()).expect("an appearance"); + let normal = dst.resolve(&ap); + let Object::Stream(s) = normal.as_ref() else { panic!("no appearance stream") }; + let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + let drawn = + if d.name(b"Name") == Some(b"PCDot".as_slice()) { content.contains(" c\n") || content.contains(" c ") } else { content.contains(" S\n") }; + assert!(drawn, "{:?}: {content}", d.name(b"Name")); + } +} + +/// A pasted copy is what the original was: typewriter text stays typewriter text (`/IT`) and a +/// Fill & Sign item, a cloud keeps its cloudy border, and PdfCraft's own keys (a turned comment's +/// `/PCBox` and `/PCAngle`) come along. A reference into the source document stays behind, and +/// keys that aren't PdfCraft's, or a malformed attribute, are not taken from the attribute. +#[test] +fn copies_keep_their_intent_cloud_and_pdfcraft_keys() { + let mut src = blank(); + let meta = |id: &str| Meta { date: Some("D:20261023120000Z".into()), id: id.into() }; + let typed = Shape::Typewriter { rect: [50.0, 500.0, 150.0, 520.0], font_size: 12.0 }; + let cloud = Shape::Polygon { vertices: vec![[200.0, 200.0], [300.0, 200.0], [250.0, 280.0]], cloud: true }; + let square = Shape::Rectangle { rect: [100.0, 100.0, 180.0, 160.0] }; + for (shape, id) in [(typed, "t1"), (cloud, "c1"), (square, "s1")] { + let new = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: format!("{id} text"), author: "Ada".into() }; + add_annotation(&mut src, &new, &meta(id)).unwrap(); + } + let annots = |doc: &Document, page: usize| -> Vec { + let pages = pdfcraft_model::pages(doc); + let list = pages[page].dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + list.iter().filter_map(|a| doc.resolve(a).as_dict().cloned()).filter(|d| !matches!(d.name(b"Subtype"), Some(b"Popup" | b"Widget"))).collect() + }; + let refs: Vec = { + let pages = pdfcraft_model::pages(&src); + let list = pages[0].dict.get(b"Annots").and_then(|a| src.resolve(a).as_array().cloned()).unwrap(); + list.iter().filter_map(Object::as_ref).collect() + }; + let target = src.add(Object::Dict(Dict::new())); + let square_ref = *refs.iter().find(|r| src.get(**r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Square".as_slice()))).unwrap(); + src.update_dict(square_ref, |d| { + d.set(b"PCBox".to_vec(), arr(&[110.0, 110.0, 170.0, 150.0])); + d.set(b"PCAngle".to_vec(), Object::Real(30.0)); + d.set(b"PCElsewhere".to_vec(), Object::Ref(target)); + }) + .unwrap(); + let which: Vec<(usize, usize)> = summaries(&src).iter().map(|c| (c.page, c.index)).collect(); + let xfdf = export_xfdf_comments(&src, &which, "src.pdf"); + assert!(xfdf.contains("intent=\"FreeTextTypeWriter\"") && xfdf.contains("style=\"cloudy\""), "{xfdf}"); + let mut dst = blank(); + import_comments(&mut dst, &xfdf, Some(1), Some("p"), Some([5.0, -5.0])).unwrap(); + let copies = annots(&dst, 1); + assert_eq!(copies.len(), 3, "{copies:?}"); + let typed = copies.iter().find(|d| d.name(b"Subtype") == Some(b"FreeText".as_slice())).unwrap(); + assert_eq!(typed.name(b"IT"), Some(b"FreeTextTypeWriter".as_slice()), "{typed:?}"); + assert!(pdfcraft_annot::is_fill_sign(&dst, typed), "still a Fill & Sign item: {typed:?}"); + assert!(matches!(typed.get(b"PCFillSign"), Some(Object::Bool(true))), "{typed:?}"); + let cloud = copies.iter().find(|d| d.name(b"Subtype") == Some(b"Polygon".as_slice())).unwrap(); + assert_eq!(cloud.name(b"IT"), Some(b"PolygonCloud".as_slice())); + let be = cloud.get(b"BE").and_then(Object::as_dict).expect("a cloudy border"); + assert_eq!(be.name(b"S"), Some(b"C".as_slice())); + assert_eq!(be.get(b"I").and_then(Object::as_f64), Some(1.0)); + let square = copies.iter().find(|d| d.name(b"Subtype") == Some(b"Square".as_slice())).unwrap(); + assert_eq!(square.get(b"PCAngle").and_then(Object::as_f64), Some(30.0)); + assert!(square.get(b"PCBox").and_then(Object::as_array).is_some_and(|b| b.len() == 4)); + assert!(square.get(b"PCElsewhere").is_none(), "a reference into the source stays behind: {square:?}"); + // File ▸ Export comments writes the standard attributes only. + let exported = export_xfdf(&src, true, false, "src.pdf"); + assert!(exported.contains("intent=\"FreeTextTypeWriter\"") && !exported.contains("pdfcraft:keys"), "{exported}"); + // A hand-made attribute: only PdfCraft keys with values of their own are taken. + let crafted = format!( + "\ + \ + " + ); + let mut third = blank(); + import_comments(&mut third, &crafted, Some(0), Some("q"), None).unwrap(); + let got = annots(&third, 0); + assert_eq!(got.len(), 2, "{got:?}"); + assert_eq!(got[0].get(b"PCAngle").and_then(Object::as_f64), Some(45.0)); + assert_eq!(got[0].name(b"Subtype"), Some(b"Square".as_slice()), "not taken from the attribute"); + assert!(got[0].get(b"PCRef").is_none() && got[1].get(b"PCAngle").is_none(), "{got:?}"); +} diff --git a/crates/xfdf/src/xfdf.rs b/crates/xfdf/src/xfdf.rs index 06ad9f34ec..e2137a98f0 100644 --- a/crates/xfdf/src/xfdf.rs +++ b/crates/xfdf/src/xfdf.rs @@ -6,22 +6,188 @@ use super::*; // ── export ────────────────────────────────────────────────────────────────────────────────── -/// Every comment as an XFDF element (one line per comment, replies included). -fn xfdf_annots(doc: &Document, out: &mut String) { +/// The annotations of `which` (0-based page, position in the page's `/Annots`) as positions, +/// with every reply to them (and to those replies), so a thread travels whole. Positions, not +/// references: other tools write comments straight into the page's array, without an object of +/// their own, and those copy too. +fn annots_with_replies(doc: &Document, which: &[(usize, usize)]) -> HashSet<(usize, usize)> { let pages = pdfcraft_model::pages(doc); - // NM of every annotation, for inreplyto. + let annots = |p: &pdfcraft_model::Page| -> Vec { + p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() + }; + /// An annotation's position, its reference (when it has one) and its parent's (`/IRT`). + struct Entry { + pos: (usize, usize), + obj: Option, + parent: Option, + } + let all: Vec = pages + .iter() + .enumerate() + .flat_map(|(pi, p)| { + annots(p).into_iter().enumerate().map(move |(ai, a)| { + let parent = doc.resolve(&a).as_dict().and_then(|d| d.get(b"IRT")).and_then(Object::as_ref); + Entry { pos: (pi, ai), obj: a.as_ref(), parent } + }) + }) + .collect(); + let mut set: HashSet<(usize, usize)> = which.iter().copied().filter(|w| all.iter().any(|e| e.pos == *w)).collect(); + // Replies point at their parent through /IRT: a pass per reply depth, bounded by the count. + for _ in 0..all.len() { + let before = set.len(); + let parents: HashSet = all.iter().filter(|e| set.contains(&e.pos)).filter_map(|e| e.obj).collect(); + for e in &all { + if e.parent.is_some_and(|parent| parents.contains(&parent)) { + set.insert(e.pos); + } + } + if set.len() == before { + break; + } + } + set +} + +/// Every comment as an XFDF element (one line per comment, replies included), or only those at +/// the positions in `only`. +/// The namespace of PdfCraft's own attributes in XFDF (`pdfcraft:keys`): other readers skip them. +pub(crate) const PDFCRAFT_NS: &str = "https://github.com/storytold/pdfcraft#xfdf"; +/// At most this much text in a `pdfcraft:keys` attribute is read. +const MAX_OWN_KEYS: usize = 64 * 1024; + +/// A value that means the same in another document: no reference or stream into this one. +fn portable(o: &Object, depth: usize) -> bool { + match o { + Object::Ref(_) | Object::Stream(_) => false, + Object::Array(a) => depth < 4 && a.iter().all(|o| portable(o, depth + 1)), + Object::Dict(d) => depth < 4 && d.iter().all(|(_, o)| portable(o, depth + 1)), + _ => true, + } +} + +/// The annotation's own PdfCraft keys (`/PC…`: a Fill & Sign item, a turned comment's box and +/// angle, …) as one PDF dictionary, for a copy that is what the original was. Values that point +/// into this document stay behind. +fn own_keys(d: &Dict) -> Option { + let mut keys = Dict::new(); + // Each value must also write as text: XML can't hold raw bytes. + let writes_as_text = |v: &Object| { + let mut out = Vec::new(); + pdfcraft_cos::serialize(v, &mut out); + std::str::from_utf8(&out).is_ok() + }; + for (k, v) in d.iter().filter(|(k, v)| k.starts_with(b"PC") && portable(v, 0) && writes_as_text(v)) { + keys.set(k.clone(), v.clone()); + } + if keys.is_empty() { + return None; + } + let mut out = Vec::new(); + pdfcraft_cos::serialize(&Object::Dict(keys), &mut out); + String::from_utf8(out).ok() +} + +/// The `pdfcraft:keys` of an XFDF comment back as dictionary entries: PdfCraft's keys only, with +/// values that mean the same here. Anything malformed is skipped. +fn read_own_keys(node: roxmltree::Node, d: &mut Dict) { + let Some(text) = node.attribute((PDFCRAFT_NS, "keys")).filter(|t| t.len() <= MAX_OWN_KEYS) else { return }; + let Ok(Object::Dict(keys)) = pdfcraft_cos::Lexer::new(text.as_bytes(), 0).object() else { return }; + for (k, v) in keys.iter().filter(|(k, v)| k.starts_with(b"PC") && portable(v, 0)) { + if !d.contains(k) { + d.set(k.clone(), v.clone()); + } + } +} + +/// The name of every annotation, for `inreplyto`: its /NM, or, for one in `only` without one +/// (other tools leave /NM out), a name new to the document for this export, so a reply copied +/// with it still finds it. +fn annotation_names(doc: &Document, pages: &[pdfcraft_model::Page], only: Option<&HashSet<(usize, usize)>>) -> HashMap { let mut names: HashMap = HashMap::new(); - for p in &pages { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { - if let Some(r) = a.as_ref() - && let Some(nm) = doc.get(r).as_dict().and_then(|d| text_of(doc, d, b"NM")) - { - names.insert(r, nm); + let mut unnamed: Vec<(usize, usize, ObjRef)> = Vec::new(); + // Every name in use, those of annotations written straight into the page array too, so a + // given name is new. + let mut taken: HashSet = HashSet::new(); + for (pi, p) in pages.iter().enumerate() { + for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() { + let nm = doc.resolve(&a).as_dict().and_then(|d| text_of(doc, d, b"NM")); + if let Some(nm) = &nm { + taken.insert(nm.clone()); + } + let Some(r) = a.as_ref() else { continue }; + match nm { + Some(nm) => { + names.insert(r, nm); + } + None if only.is_some_and(|only| only.contains(&(pi, ai))) => unnamed.push((pi, ai, r)), + None => {} } } } + for (pi, ai, r) in unnamed { + let mut name = format!("pdfcraft-{pi}-{ai}"); + while taken.contains(&name) { + name.push('x'); + } + taken.insert(name.clone()); + names.insert(r, name); + } + names +} + +/// The elements inside a comment's XFDF element: its text, default appearance, ink, vertices +/// and pop-up (on page `pi`). +fn xfdf_annot_children(doc: &Document, out: &mut String, d: &Dict, pi: usize) { + if let Some(c) = text_of(doc, d, b"Contents") { + let _ = write!(out, "{}", esc(&c)); + } + if let Some(da) = text_of(doc, d, b"DA") { + let _ = write!(out, "{}", esc(&da)); + } + let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + if !ink.is_empty() { + out.push_str(""); + for g in ink { + let v = nums_of(doc, Some(&g)); + let pts: Vec = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); + let _ = write!(out, "{}", pts.join(";")); + } + out.push_str(""); + } + // Polygon and polyline points (XFDF ``: `x,y;x,y;…`). + let vertices = nums_of(doc, d.get(b"Vertices")); + let pts: Vec = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); + if !pts.is_empty() { + let _ = write!(out, "{}", pts.join(";")); + } + if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { + let r = nums_of(doc, pd.get(b"Rect")); + if r.len() == 4 { + let open = matches!(pd.get(b"Open"), Some(Object::Bool(true))); + let _ = write!( + out, + "", + n(r[0]), + n(r[1]), + n(r[2]), + n(r[3]), + if open { "yes" } else { "no" } + ); + } + } +} + +/// `own`: write each comment's PdfCraft keys too (see [`own_keys`]), for a copy and paste. +fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, usize)>>, own: bool) { + let pages = pdfcraft_model::pages(doc); + // NM of every annotation, for inreplyto. A selected annotation without one (other tools + // leave /NM out) gets a name for this export, so a reply copied with it still finds it. + let names = annotation_names(doc, &pages, only); for (pi, p) in pages.iter().enumerate() { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() { + if only.is_some_and(|only| !only.contains(&(pi, ai))) { + continue; + } let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; let Some(sub) = d.name(b"Subtype") else { continue }; let Some((_, tag)) = SUBTYPES.iter().find(|(s, _)| s.as_bytes() == sub) else { continue }; @@ -37,9 +203,12 @@ fn xfdf_annots(doc: &Document, out: &mut String) { n(rect[1].max(rect[3])) ); } + // The name: /NM, or the one given above for this export. + if let Some(nm) = text_of(doc, &d, b"NM").or_else(|| a.as_ref().and_then(|r| names.get(&r).cloned())) { + let _ = write!(attrs, " name=\"{}\"", esc(&nm)); + } for (key, attr) in [ - (&b"NM"[..], "name"), - (b"T", "title"), + (&b"T"[..], "title"), (b"Subj", "subject"), (b"M", "date"), (b"CreationDate", "creationdate"), @@ -81,7 +250,7 @@ fn xfdf_annots(doc: &Document, out: &mut String) { let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into()); let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", esc(&name(&le[0])), esc(&name(&le[1]))); } - // A callout: its leader path, text-box inset, intent and (single-name) line ending. + // A callout: its leader path, text-box inset and (single-name) line ending. let cl = nums_of(doc, d.get(b"CL")); if cl.len() == 4 || cl.len() == 6 { let _ = write!(attrs, " callout=\"{}\"", cl.iter().map(|v| n(*v)).collect::>().join(",")); @@ -89,54 +258,31 @@ fn xfdf_annots(doc: &Document, out: &mut String) { if rd.len() == 4 { let _ = write!(attrs, " fringe=\"{}\"", rd.iter().map(|v| n(*v)).collect::>().join(",")); } - if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { - let _ = write!(attrs, " intent=\"{}\"", esc(&it)); - } if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { let _ = write!(attrs, " head=\"{}\"", esc(&le)); } } + // What the comment is meant as (/IT): typewriter text, a callout, a cloud, an arrow. + if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { + let _ = write!(attrs, " intent=\"{}\"", esc(&it)); + } + // A cloudy border (/BE /S /C) and how far its arcs bulge. + if let Some(be) = + d.get(b"BE").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).filter(|b| b.name(b"S") == Some(b"C".as_slice())) + { + attrs.push_str(" style=\"cloudy\""); + if let Some(i) = be.get(b"I").and_then(Object::as_f64) { + let _ = write!(attrs, " intensity=\"{}\"", n(i)); + } + } if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) { let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt)); } + if own && let Some(keys) = own_keys(&d) { + let _ = write!(attrs, " pdfcraft:keys=\"{}\"", esc(&keys)); + } let _ = write!(out, "<{tag}{attrs}>"); - if let Some(c) = text_of(doc, &d, b"Contents") { - let _ = write!(out, "{}", esc(&c)); - } - if let Some(da) = text_of(doc, &d, b"DA") { - let _ = write!(out, "{}", esc(&da)); - } - let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - if !ink.is_empty() { - out.push_str(""); - for g in ink { - let v = nums_of(doc, Some(&g)); - let pts: Vec = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); - let _ = write!(out, "{}", pts.join(";")); - } - out.push_str(""); - } - // Polygon and polyline points (XFDF ``: `x,y;x,y;…`). - let vertices = nums_of(doc, d.get(b"Vertices")); - let pts: Vec = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); - if !pts.is_empty() { - let _ = write!(out, "{}", pts.join(";")); - } - if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { - let r = nums_of(doc, pd.get(b"Rect")); - if r.len() == 4 { - let open = matches!(pd.get(b"Open"), Some(Object::Bool(true))); - let _ = write!( - out, - "", - n(r[0]), - n(r[1]), - n(r[2]), - n(r[3]), - if open { "yes" } else { "no" } - ); - } - } + xfdf_annot_children(doc, out, &d, pi); let _ = writeln!(out, ""); } } @@ -191,12 +337,235 @@ fn xfdf_fields(doc: &Document, out: &mut String) { } } +/// XFDF with these comments alone (0-based page and position in the page's `/Annots`), each +/// with its replies: what Copy puts on the clipboard, and Export selected comments writes. +pub fn export_xfdf_comments(doc: &Document, which: &[(usize, usize)], file: &str) -> String { + let only = annots_with_replies(doc, which); + let mut out = format!( + "\n\n" + ); + out.push_str("\n"); + xfdf_annots(doc, &mut out, Some(&only), true); + out.push_str("\n"); + let _ = writeln!(out, "", esc(file)); + out.push_str("\n"); + out +} + +/// Whether `text` is XFDF with comments in it (what a paste of copied comments carries). +pub fn is_xfdf_comments(text: &str) -> bool { + let head: String = text.chars().take(400).collect(); + head.contains(" }, + /// A line or arrow, and a callout's leader. + Line { points: Vec<[f64; 2]>, color: Option<[f64; 3]> }, + /// A polygon (closed) or connected lines. + Poly { points: Vec<[f64; 2]>, closed: bool, color: Option<[f64; 3]> }, + /// Freehand strokes. + Ink { strokes: Vec>, color: Option<[f64; 3]> }, + /// Text markup: each quad is x1,y1 (upper left), x2,y2 (upper right), x3,y3 (lower left), x4,y4. + Quads { quads: Vec<[f64; 8]>, color: Option<[f64; 3]> }, + /// A text box's text, from the top-left of its box (`rect`, which it wraps in), at its font + /// `size` in points and in its text colour. + Text { rect: [f64; 4], text: String, size: f64, color: Option<[f64; 3]> }, +} + +/// Every comment element of an XFDF text, in order (none when it is no XFDF). +fn with_comment_nodes(text: &str, mut f: impl FnMut(roxmltree::Node<'_, '_>)) { + if check_form_xml_depth(text).is_err() { + return; + } + let Ok(xml) = parse_form_xml(text) else { return }; + let root = xml.root_element(); + if root.tag_name().name() != "xfdf" { + return; + } + let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) else { return }; + for node in annots.children().filter(|c| c.is_element()) { + if SUBTYPES.iter().any(|(_, t)| *t == node.tag_name().name().to_ascii_lowercase()) { + f(node); + } + } +} + +/// The comment elements of an XFDF text: tag, `inreplyto`, rect. +fn comment_nodes(text: &str) -> Vec<(String, Option, Vec)> { + let mut out = Vec::new(); + with_comment_nodes(text, |node| { + out.push(( + node.tag_name().name().to_owned(), + node.attribute("inreplyto").map(str::to_owned), + node.attribute("rect").map(csv).unwrap_or_default(), + )); + }); + out +} + +/// Pairs `x,y;x,y;…` (XFDF vertices and ink gestures). +fn pairs(text: &str) -> Vec<[f64; 2]> { + text.split(';').filter_map(|p| if let [x, y] = csv(p).as_slice() { Some([*x, *y]) } else { None }).collect() +} + +/// The outlines of the comments in an XFDF text (replies, which have no place of their own, +/// left out): what a paste shows hanging on the pointer. +pub fn comment_outlines(text: &str) -> Vec { + let mut out = Vec::new(); + with_comment_nodes(text, |node| { + if node.attribute("inreplyto").is_some() { + return; + } + let tag = node.tag_name().name().to_ascii_lowercase(); + let color = node.attribute("color").and_then(parse_hex); + let rect = node + .attribute("rect") + .map(csv) + .filter(|r| r.len() == 4 && r.iter().all(|v| v.is_finite())) + .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]); + match tag.as_str() { + "line" => { + if let (Some(a), Some(b)) = (node.attribute("start").map(csv), node.attribute("end").map(csv)) + && let ([ax, ay], [bx, by]) = (a.as_slice(), b.as_slice()) + { + out.push(Outline::Line { points: vec![[*ax, *ay], [*bx, *by]], color }); + } + } + "polygon" | "polyline" => { + let points = node.children().find(|c| c.has_tag_name("vertices")).and_then(|v| v.text()).map(pairs).unwrap_or_default(); + if points.len() >= 2 { + out.push(Outline::Poly { points, closed: tag == "polygon", color }); + } + } + "ink" => { + let strokes: Vec> = node + .children() + .find(|c| c.has_tag_name("inklist")) + .map(|list| { + list.children().filter(|g| g.has_tag_name("gesture")).filter_map(|g| g.text()).map(pairs).filter(|s| !s.is_empty()).collect() + }) + .unwrap_or_default(); + if !strokes.is_empty() { + out.push(Outline::Ink { strokes, color }); + } + } + "highlight" | "underline" | "strikeout" | "squiggly" | "redact" => { + let coords = node.attribute("coords").map(csv).unwrap_or_default(); + let quads: Vec<[f64; 8]> = coords.as_chunks::<8>().0.to_vec(); + if quads.is_empty() { + if let Some(rect) = rect { + out.push(Outline::Rect { rect, round: false, color }); + } + } else { + out.push(Outline::Quads { quads, color }); + } + } + // A Fill & Sign mark: its tick, cross, dot or line, as its appearance draws it. + "stamp" + if let Some(rect) = rect + && let Some(mark) = node.attribute("icon").and_then(|i| pdfcraft_annot::FillMark::from_name(i.as_bytes())) => + { + if mark == pdfcraft_annot::FillMark::Dot { + let r = (rect[2] - rect[0]).min(rect[3] - rect[1]) * 0.3; + let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0); + out.push(Outline::Rect { rect: [cx - r, cy - r, cx + r, cy + r], round: true, color }); + } + for points in mark.strokes(rect) { + out.push(Outline::Line { points, color }); + } + } + _ => { + if let Some(rect) = rect { + out.push(Outline::Rect { rect, round: tag == "circle", color }); + } + // A callout's leader, from the box to what it points at. + let callout = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6); + if tag == "freetext" + && let Some(cl) = &callout + { + out.push(Outline::Line { points: cl.as_chunks::<2>().0.to_vec(), color }); + } + // A text box's text, in its own colour and size, inside the box (a callout's box + // is its rect inset by the fringe). + if tag == "freetext" + && let Some(rect) = rect + && let Some(text) = node.children().find(|c| c.has_tag_name("contents")).and_then(|c| c.text()).filter(|t| !t.trim().is_empty()) + { + let mut da = Dict::new(); + if let Some(s) = node.children().find(|c| c.has_tag_name("defaultappearance")).and_then(|c| c.text()) { + da.set(b"DA".to_vec(), PdfString::literal(s.as_bytes().to_vec())); + } + let (text_color, size) = pdfcraft_annot::appearance::parse_da(&da); + let fringe = node.attribute("fringe").map(csv).filter(|f| callout.is_some() && f.len() == 4 && f.iter().all(|v| v.is_finite())); + let rect = match fringe.as_deref() { + Some([l, b, r, t]) => [rect[0] + l, rect[1] + b, rect[2] - r, rect[3] - t], + _ => rect, + }; + out.push(Outline::Text { rect, text: text.replace("\r\n", "\n").replace('\r', "\n"), size, color: Some(text_color) }); + } + } + } + }); + out +} + +/// How many comments (replies not counted) an XFDF text holds. +pub fn comment_count(text: &str) -> usize { + comment_nodes(text).iter().filter(|(_, irt, _)| irt.is_none()).count() +} + +/// The box around every comment in an XFDF text, in the pages' user space: [x0, y0, x1, y1], +/// or `None` without a comment with a rect. What a paste shows hanging on the pointer and centres +/// on the point: replies, which have no place of their own (some tools still give them a rect, at +/// 0,0 or away from their parent), are left out, as [`comment_outlines`] leaves them out. +pub fn comments_bounds(text: &str) -> Option<[f64; 4]> { + let mut bounds: Option<[f64; 4]> = None; + for (_, in_reply_to, rect) in comment_nodes(text) { + if in_reply_to.is_some() { + continue; + } + if let [x0, y0, x1, y1] = rect.as_slice() + && rect.iter().all(|v| v.is_finite()) + { + let r = [x0.min(*x1), y0.min(*y1), x0.max(*x1), y0.max(*y1)]; + bounds = Some(match bounds { + None => r, + Some(b) => [b[0].min(r[0]), b[1].min(r[1]), b[2].max(r[2]), b[3].max(r[3])], + }); + } + } + bounds +} + +/// Move an annotation's geometry by (dx, dy) in user space: its rect, quads, vertices, ink, +/// line ends and callout leader (what a copy placed elsewhere needs). +fn translate(d: &mut Dict, dx: f64, dy: f64) { + let shift = |o: &Object| -> Option { + let v: Vec = o.as_array()?.iter().map(|x| x.as_f64()).collect::>>()?; + Some(arr(&v.iter().enumerate().map(|(i, x)| if i % 2 == 0 { x + dx } else { x + dy }).collect::>())) + }; + for key in [&b"Rect"[..], b"QuadPoints", b"Vertices", b"L", b"CL"] { + if let Some(moved) = d.get(key).and_then(shift) { + d.set(key.to_vec(), moved); + } + } + if let Some(Object::Array(strokes)) = d.get(b"InkList").cloned() { + let moved: Vec = strokes.iter().map(|s| shift(s).unwrap_or_else(|| s.clone())).collect(); + d.set(b"InkList".to_vec(), Object::Array(moved)); + } +} + /// XFDF with the document's comments and/or field values. pub fn export_xfdf(doc: &Document, comments: bool, fields: bool, file: &str) -> String { let mut out = String::from("\n\n"); if comments { out.push_str("\n"); - xfdf_annots(doc, &mut out); + xfdf_annots(doc, &mut out, None, false); out.push_str("\n"); } if fields { @@ -311,6 +680,16 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]), ); } + if let Some(it) = node.attribute("intent").filter(|it| !it.is_empty()) { + d.set(b"IT".to_vec(), Object::name(it)); + } + if node.attribute("style") == Some("cloudy") { + let mut be = Dict::new(); + be.set(b"S".to_vec(), Object::name("C")); + let intensity = node.attribute("intensity").and_then(|i| i.parse::().ok()).filter(|i| i.is_finite()).unwrap_or(1.0); + be.set(b"I".to_vec(), Object::Real(intensity.clamp(0.0, 2.0))); + d.set(b"BE".to_vec(), Object::Dict(be)); + } if let Some(cl) = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6) { d.set(b"CL".to_vec(), arr(&cl)); if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) { @@ -352,6 +731,7 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt _ => {} } } + read_own_keys(node, &mut d); Some(d) } @@ -472,7 +852,32 @@ pub(crate) fn add_popup(doc: &mut Document, page: ObjRef, parent: ObjRef, rect: Ok(()) } +/// Import XFDF comments as copies: every comment lands on `page` (0-based; its own page when +/// `None`) and, with `fresh` (a prefix unique to this import), under a new name, so nothing in +/// the document is replaced and pasting the same comments twice gives two copies. Replies keep +/// their thread among the pasted comments; a reply to a comment that was not copied stands +/// alone. Field values in the file are ignored. +pub fn import_comments( + doc: &mut Document, + text: &str, + page: Option, + fresh: Option<&str>, + offset: Option<[f64; 2]>, +) -> Result { + import_xfdf_with(doc, text, page, fresh, offset.filter(|o| o.iter().all(|v| v.is_finite()))) +} + pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result { + import_xfdf_with(doc, text, None, None, None) +} + +fn import_xfdf_with( + doc: &mut Document, + text: &str, + target_page: Option, + fresh: Option<&str>, + offset: Option<[f64; 2]>, +) -> Result { check_form_xml_depth(text)?; let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?; let root = xml.root_element(); @@ -481,28 +886,65 @@ pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); + if let Some(p) = target_page + && p >= pages.len() + { + return Err(DataError::Malformed(format!("page {} is not in the document", p + 1))); + } // Comments: parents first, then replies (whose /IRT needs the parent's reference). let mut by_name: HashMap = HashMap::new(); let mut later: HashMap = HashMap::new(); + // Fresh names: every copy gets one from its own position, and `name` → the first copy's, + // so replies find their copied parent (a name two source comments share, as other tools + // write, still gives two copies). + let mut renamed: HashMap = HashMap::new(); + if let (Some(prefix), Some(annots)) = (fresh, root.children().find(|c| c.has_tag_name("annots"))) { + for (n, node) in annots.children().filter(|c| c.is_element()).enumerate() { + if let Some(nm) = node.attribute("name") { + renamed.entry(nm.to_string()).or_insert_with(|| format!("{prefix}-{}", n + 1)); + } + } + } if let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) { - for node in annots.children().filter(|c| c.is_element()) { + for (n, node) in annots.children().filter(|c| c.is_element()).enumerate() { let tag = node.tag_name().name().to_ascii_lowercase(); let Some((subtype, _)) = SUBTYPES.iter().find(|(_, t)| *t == tag) else { continue }; - let Some(page) = node.attribute("page").and_then(|p| p.parse::().ok()).and_then(|p| pages.get(p).copied()) else { continue }; - let Some(d) = annot_from_xml(node, subtype, page) else { continue }; + let own_page = node.attribute("page").and_then(|p| p.parse::().ok()); + let Some(page) = target_page.or(own_page).and_then(|p| pages.get(p).copied()) else { continue }; + let Some(mut d) = annot_from_xml(node, subtype, page) else { continue }; + let assigned = fresh.map(|prefix| format!("{prefix}-{}", n + 1)); + if let Some(name) = &assigned { + // A copy under a name of its own (one even where the original had none). + d.set(b"NM".to_vec(), PdfString::text(name)); + } + if let Some([dx, dy]) = offset { + translate(&mut d, dx, dy); + } let r = place(doc, page, d)?; // A later same-name record replaces this comment, including its reply relation. later.remove(&r); - if let Some(nm) = node.attribute("name") { - by_name.insert(nm.to_string(), r); + if let Some(name) = assigned.clone().or_else(|| node.attribute("name").map(str::to_owned)) { + by_name.insert(name, r); } if let Some(irt) = node.attribute("inreplyto") { - later.insert(r, irt.to_string()); + match (fresh, renamed.get(irt)) { + // The parent is among the copies: the reply follows it. + (Some(_), Some(parent)) => later.insert(r, parent.clone()), + // A copy's parent was not copied: the reply stands alone rather than + // attaching to a same-named comment the document happens to have. + (Some(_), None) => None, + (None, _) => later.insert(r, irt.to_string()), + }; } // A pop-up window. if let Some(pop) = node.children().find(|c| c.has_tag_name("popup")) - && let Some(rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4) + && let Some(mut rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4) { + if let Some([dx, dy]) = offset { + for (i, v) in rect.iter_mut().enumerate() { + *v += if i % 2 == 0 { dx } else { dy }; + } + } add_popup(doc, page, r, &rect, pop.attribute("open") == Some("yes"))?; } report.comments += 1; @@ -513,6 +955,10 @@ pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result Date: Thu, 5 Nov 2026 17:08:08 +0100 Subject: [PATCH 12/43] Fill & Sign: a Border control for typed text, and a border colour of its own (#4146) * Fill & Sign: a Border control for typed text, and a border colour of its own Rebased onto main as one commit: main's Fill & Sign text colour (#2444) runs through the same places as the border (the open box, typed(), the page input, the toolbar and the preferences), so every step of the branch met it again. Text colour and border now travel together as TypedLook (typed_with), which keeps typed() and the new function under Clippy's argument limit; a border without its own colour takes the text's. Make Current Properties Default on typed text also keeps its colour for new text now. The branch's commits, in order: - Typewriter text: a border change grows its box on every side, so the text keeps its place - Fill & Sign: a Border control for typed text and dates - Parity: the typewriter border's evidence - i18n: the Border label in every catalog, recorded in ATTRIBUTION.toml - Text boxes, typewriter text and callouts: a border colour of their own - Fill & Sign: the toolbar follows the selected text; a Border colour button - fill_sign_add and comment_edit take `border_color` - Parity: the border colour and the toolbar following the selection - i18n: Border colour and Same as text in every catalog, recorded in ATTRIBUTION.toml - Typed text's border: /Border's width is read, a non-finite border colour refused, a saved Same as text restored - Fill & Sign: the toolbar changes only the selected text; Make Current Properties Default keeps new text's border - Review: Make Properties Default gives a text box's or callout's tool its border colour - i18n: the new labels in the Persian catalog, recorded in ATTRIBUTION.toml - Review: a test that dragging the Font size control resizes the selected text to where the drag ends - Rebase: the typed text's border in main's annot and comments modules (Style's border colour, exports), and craft_ui's ComboBox Co-Authored-By: Claude Opus 5.5 * Open the Font size popup first in the drag test: main moved the size into a popup --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps --- crates/annot/src/appearance.rs | 31 ++- crates/annot/src/create.rs | 7 +- crates/annot/src/info.rs | 3 + crates/annot/src/lib.rs | 5 +- crates/annot/src/modify.rs | 50 ++++- crates/annot/src/tests.rs | 79 ++++++++ crates/annot/src/types.rs | 5 +- crates/automation/src/comments.rs | 27 ++- crates/automation/src/tools.rs | 7 +- crates/automation/tests/it/automation.rs | 42 ++++ crates/engine/src/edit.rs | 7 + crates/engine/src/edit/labels.rs | 1 + crates/engine/src/edit/run.rs | 2 + crates/engine/src/history.rs | 1 + crates/ui-egui/src/app.rs | 3 + crates/ui-egui/src/app/prefs.rs | 30 +++ crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/canvas/page_input.rs | 2 + crates/ui-egui/src/dialogs/comment_props.rs | 46 ++++- crates/ui-egui/src/editing.rs | 4 +- crates/ui-egui/src/hotbar.rs | 75 +++++++ crates/ui-egui/src/lib.rs | 8 + crates/ui-egui/src/panels/comments_panel.rs | 2 +- crates/ui-egui/src/tools/comments.rs | 27 ++- crates/ui-egui/src/tools/comments/menu.rs | 31 ++- crates/ui-egui/src/tools/fill_sign.rs | 89 +++++++-- crates/ui-egui/tests/it/comments.rs | 31 +++ crates/ui-egui/tests/it/control.rs | 1 + crates/ui-egui/tests/it/fill_sign.rs | 208 +++++++++++++++++++- crates/ui-egui/tests/it/ui.rs | 18 ++ crates/ui-egui/tests/it/viewer.rs | 10 +- parity/acrobat-features.toml | 8 +- 32 files changed, 811 insertions(+), 51 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index c2f7f5e53d..9cdb548360 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -232,6 +232,31 @@ fn rg_stroke(c: Rgb) -> String { format!("{} {} {} RG\n", n(c[0]), n(c[1]), n(c[2])) } +/// A FreeText's border colour where it has its own: the stroking colour in `/DA` (`RG`, or `G` +/// for grey). `None`: the border takes the text colour. +pub fn da_border(d: &Dict) -> Option { + let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned())?; + let toks: Vec<&str> = da.split_whitespace().collect(); + let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()).filter(|x| x.is_finite()); + let mut col = None; + for (i, t) in toks.iter().enumerate() { + match *t { + "RG" if i >= 3 => { + if let (Some(r), Some(g), Some(b)) = (num(i - 3), num(i - 2), num(i - 1)) { + col = Some([r, g, b].map(|x| x.clamp(0.0, 1.0))); + } + } + "G" if i >= 1 => { + if let Some(g) = num(i - 1) { + col = Some([g.clamp(0.0, 1.0); 3]); + } + } + _ => {} + } + } + col +} + /// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt. pub fn parse_da(d: &Dict) -> (Rgb, f64) { let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap_or_default(); @@ -921,6 +946,8 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin let mut res = std::mem::take(&mut drawing.resources); let _tail = std::mem::take(&mut drawing.tail); let (text_color, size) = parse_da(d); + // The box's border and a callout's leader line: their own colour, else the text's. + let line_color = da_border(d).unwrap_or(text_color); let bg = stroke; let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; // A callout: the leader line (arrowhead at its first point), and the text box inside @@ -959,7 +986,7 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { return None; } - c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); + c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(line_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); for (i, p) in pts.iter().enumerate() { c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); } @@ -975,7 +1002,7 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin let h = bw / 2.0; c.push_str(&format!( "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", - rg_stroke(text_color), + rg_stroke(line_color), n(bw), dash(d), n(rect[0] + h), diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 298dc67f41..f12b2bba3b 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,6 +1,6 @@ use super::draw::draw_appearance; use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; -use super::modify::{n, rich_text, set_part_keys}; +use super::modify::{free_text_da, n, rich_text, set_part_keys}; use super::types::{ FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, }; @@ -385,8 +385,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter")); } let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 }; - let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0)); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); + d.set(b"DA".to_vec(), free_text_da(style.color, style.border_color, size)); d.set(b"Q".to_vec(), Object::Int(0)); if let Some(f) = style.fill { d.set(b"C".to_vec(), rgb(f)); @@ -426,7 +425,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } fn validate_style(style: &Style) -> Result<(), AnnotError> { - if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { + if !finite(&style.color) || style.border_color.is_some_and(|c| !finite(&c)) || !style.opacity.is_finite() || !style.width.is_finite() { return Err(AnnotError::Invalid("invalid style".into())); } Ok(()) diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index ce001bcf29..cf1b607664 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -217,6 +217,8 @@ pub struct Props { pub fill: Option, /// Text boxes, typewriter text and callouts: the `/DA` font size in points. pub font_size: Option, + /// Text boxes, typewriter text and callouts: the border's own colour (`None`: the text's). + pub border_color: Option, /// How opaque the fill is drawn, for a comment with a fill control (`opacity` is then its /// line's or border's). pub fill_opacity: Option, @@ -253,6 +255,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { fillable: fillable(&subtype), fill: fill_of(d), font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), + border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(), fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill), text_opacity: (subtype == "FreeText").then(|| appearance::part_opacity(d).text), subtype, diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 6d555dbb9c..270be0115d 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -41,8 +41,9 @@ pub use lookup::{ pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ - add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, - set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, + add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, + set_border_color, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, set_review_state, + set_style, with_replies_and_popups, }; pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model}; pub use types::{ diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 469815af7f..8dbfd4c5b9 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -528,6 +528,8 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } } +/// The border width as the appearance draws it: `/BS /W`, else the third number of `/Border` +/// (another app's), else 1. fn border_width_of(d: &Dict) -> f64 { appearance::border_width(d) } @@ -582,13 +584,24 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha } refuse_foreign_text(doc, &d)?; let free_text = subtype == "FreeText"; + // Typewriter text has no box of its own: a border goes around its text, so the box grows + // (or shrinks) by the change on every side and the text keeps its place and its lines. + let typewriter = free_text && d.name(b"IT") == Some(b"FreeTextTypeWriter") && !d.contains(b"CL"); + let regrow = width.filter(|_| typewriter).and_then(|w| { + let by = w.max(0.0) - if d.contains(b"BS") || d.contains(b"Border") { border_width_of(&d) } else { 0.0 }; + let rect: Vec = d.get(b"Rect")?.as_array()?.iter().filter_map(|o| o.as_f64()).collect(); + let rect = grow(normalize(four(&rect)?), by); + (by != 0.0 && finite(&rect) && rect[2] - rect[0] >= 1.0 && rect[3] - rect[1] >= 1.0).then_some(rect) + }); let before = d; doc.update_dict(r, |d| { + if let Some(rect) = regrow { + d.set(b"Rect".to_vec(), num_array(&rect)); + } if let Some(c) = color { if free_text { let (_, size) = appearance::parse_da(d); - let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0)); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); + d.set(b"DA".to_vec(), free_text_da(c, appearance::da_border(d), size)); rich_text(d); } else { d.set(b"C".to_vec(), rgb(c)); @@ -627,6 +640,37 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha redraw_or_restore(doc, r, before) } +/// A FreeText's `/DA`: the text colour, the border's own colour when it has one (the stroking +/// colour, `RG`), Helvetica at `size`. +pub(crate) fn free_text_da(text: Rgb, border: Option, size: f64) -> PdfString { + let [r, g, b] = text.map(|x| x.clamp(0.0, 1.0)); + let stroke = border.map(|c| c.map(|x| x.clamp(0.0, 1.0))).map(|[r, g, b]| format!("{} {} {} RG ", n(r), n(g), n(b))).unwrap_or_default(); + PdfString::literal(format!("{} {} {} rg {stroke}/Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes()) +} + +/// Give a text box, typewriter text or callout a border colour of its own (`None`: the text's +/// again), and redraw it. +pub fn set_border_color(doc: &mut Document, page: usize, index: usize, color: Option, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"FreeText") { + return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a border colour".into())); + } + if color.is_some_and(|c| !finite(&c)) { + return Err(AnnotError::Invalid("invalid colour".into())); + } + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + let (text, size) = appearance::parse_da(d); + d.set(b"DA".to_vec(), free_text_da(text, color, size)); + rich_text(d); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + /// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the /// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new /// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt. @@ -644,7 +688,7 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m let before = d; doc.update_dict(r, |d| { let (col, _) = appearance::parse_da(d); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes())); + d.set(b"DA".to_vec(), free_text_da(col, appearance::da_border(d), size)); rich_text(d); touch(d, meta); })?; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 91b36550cf..f451ad0f8e 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -668,6 +668,85 @@ fn fill_and_sign_items_are_drawn() { assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err()); } +/// A border chosen for typewriter text goes around it in the text's colour. Changing it grows or +/// shrinks the box on every side, so the text stays where it was typed; a text box keeps its box. +#[test] +fn a_typewriter_border_goes_around_its_text_which_stays_put() { + let mut doc = fixture(); + let shape = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 }; + let style = Style { width: 1.0, ..Style::default_for(&shape) }; + let t = add_annotation(&mut doc, &NewAnnotation { page: 1, shape, style, contents: "Ada".into(), author: "Ada".into() }, &meta("x")).unwrap(); + // The text starts 2 pt inside the border: 100 + 1 + 2 across, 716 - 1 - 2 - 0.9 em down. + let at = "1 0 0 1 103 703.1 Tm"; + let d = &list(&doc, 1)[t]; + let ap = ap_content(&doc, d); + assert!(ap.contains("1 w\n") && ap.contains(" re S"), "a 1 pt border: {ap}"); + assert!(ap.contains(at), "{ap}"); + for (width, expected) in [(2.0, [99.0, 699.0, 201.0, 717.0]), (0.0, [101.0, 701.0, 199.0, 715.0])] { + set_style(&mut doc, 1, t, StyleChange::new(None, None, Some(width), None), &meta("")).unwrap(); + let d = &list(&doc, 1)[t]; + assert_eq!(rect(d), expected, "{width} pt border"); + let ap = ap_content(&doc, d); + assert!(ap.contains(at), "{width} pt: the text keeps its place: {ap}"); + assert_eq!(ap.contains(" re S"), width > 0.0, "{width} pt: {ap}"); + } + let shape = Shape::TextBox { rect: [100.0, 600.0, 200.0, 650.0], font_size: 11.0 }; + let b = add_annotation(&mut doc, &new(1, shape), &meta("b")).unwrap(); + set_style(&mut doc, 1, b, StyleChange::new(None, None, Some(3.0), None), &meta("")).unwrap(); + assert_eq!(rect(&list(&doc, 1)[b]), [100.0, 600.0, 200.0, 650.0], "a text box keeps the box it was drawn with"); + // Another app's typed text with its width in /Border (no /BS): 4 pt, so 2 pt shrinks the box + // by 2 on every side, as it is drawn. + let shape = Shape::Typewriter { rect: [100.0, 500.0, 200.0, 530.0], font_size: 11.0 }; + let o = add_annotation( + &mut doc, + &NewAnnotation { page: 1, style: Style::default_for(&shape), shape: shape.clone(), contents: "Bo".into(), author: "Ada".into() }, + &meta("o"), + ) + .unwrap(); + let (_, r) = annot_ref(&mut doc, 1, o).unwrap(); + doc.update_dict(r, |d| { + d.remove(b"BS"); + d.set(b"Border".to_vec(), num_array(&[0.0, 0.0, 4.0])); + }) + .unwrap(); + set_style(&mut doc, 1, o, StyleChange::new(None, None, Some(2.0), None), &meta("")).unwrap(); + assert_eq!(rect(&list(&doc, 1)[o]), [102.0, 502.0, 198.0, 528.0], "the imported 4 pt border is read from /Border"); + // A border colour that isn't a colour is refused before anything is written. + let mut bad = NewAnnotation { page: 1, style: Style::default_for(&shape), shape, contents: "x".into(), author: "Ada".into() }; + bad.style.border_color = Some([f64::NAN, 0.0, 0.0]); + let before = list(&doc, 1).len(); + assert!(matches!(add_annotation(&mut doc, &bad, &meta("n")), Err(AnnotError::Invalid(_)))); + assert_eq!(list(&doc, 1).len(), before); +} + +/// Text keeps its colour and the border gets its own: the stroking colour in `/DA`, which a new +/// text colour or font size keeps, and which `None` gives back to the text's. +#[test] +fn a_text_border_can_have_its_own_colour() { + let mut doc = fixture(); + let shape = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 }; + let style = Style { color: [0.0, 0.5, 0.0], width: 1.0, border_color: Some([1.0, 0.0, 0.0]), ..Style::default_for(&shape) }; + let t = add_annotation(&mut doc, &NewAnnotation { page: 1, shape, style, contents: "Ada".into(), author: "Ada".into() }, &meta("x")).unwrap(); + let da = |doc: &Document| text(&list(doc, 1)[t], b"DA"); + assert_eq!(da(&doc), "0 0.5 0 rg 1 0 0 RG /Helv 11 Tf"); + let ap = ap_content(&doc, &list(&doc, 1)[t]); + assert!(ap.contains("1 0 0 RG\n1 w"), "the border in red: {ap}"); + assert!(ap.contains("0 0.5 0 rg"), "the text in green: {ap}"); + assert_eq!(props(&doc, 1, t).unwrap().border_color, Some([1.0, 0.0, 0.0])); + set_style(&mut doc, 1, t, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); + assert_eq!(da(&doc), "0 0 1 rg 1 0 0 RG /Helv 11 Tf", "a new text colour keeps the border's"); + set_font_size(&mut doc, 1, t, 14.0, &meta("")).unwrap(); + assert_eq!(da(&doc), "0 0 1 rg 1 0 0 RG /Helv 14 Tf", "so does a new size"); + set_border_color(&mut doc, 1, t, Some([0.5, 0.5, 0.5]), &meta("")).unwrap(); + assert!(ap_content(&doc, &list(&doc, 1)[t]).contains("0.5 0.5 0.5 RG\n1 w")); + set_border_color(&mut doc, 1, t, None, &meta("")).unwrap(); + assert_eq!(da(&doc), "0 0 1 rg /Helv 14 Tf"); + assert!(ap_content(&doc, &list(&doc, 1)[t]).contains("0 0 1 RG\n1 w"), "the text's colour again"); + assert_eq!(props(&doc, 1, t).unwrap().border_color, None); + let m = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }), &meta("m")).unwrap(); + assert!(set_border_color(&mut doc, 0, m, Some([1.0, 0.0, 0.0]), &meta("")).is_err(), "a rectangle's colour is its border"); +} + #[test] fn author_subject_and_note_icon_change() { let mut doc = fixture(); diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 935eff8759..abc5c17601 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -588,6 +588,9 @@ pub struct Style { pub width: f64, /// Interior fill for rectangles and ovals (`/IC`), background for text boxes. pub fill: Option, + /// A text box's, typewriter text's or callout's border colour where it differs from the + /// text's (`None`: the text colour), the stroking colour in its `/DA`. + pub border_color: Option, /// The fill's own opacity, 0–1 (`/ca`), where it differs from `opacity` (which is then the /// line's or border's); `None`: as `opacity`. pub fill_opacity: Option, @@ -624,7 +627,7 @@ pub struct TextReplacement<'a> { impl Default for Style { fn default() -> Self { - Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None, fill_opacity: None, text_opacity: None } + Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None, fill_opacity: None, text_opacity: None, border_color: None } } } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 6adc95b2ab..b8b815ac37 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -553,6 +553,18 @@ impl Automation { if !(4.0..=300.0).contains(&size) { return Err(ToolError::InvalidArgs("font_size must be 4–300 pt".into())); } + let requested_border = a.opt_num("border")?; + if requested_border.is_some() && !matches!(kind, "text" | "date") { + return Err(ToolError::InvalidArgs("border is only for type text or date".into())); + } + let border = requested_border.unwrap_or(0.0); + if !(0.0..=12.0).contains(&border) { + return Err(ToolError::InvalidArgs("border must be 0–12 pt".into())); + } + let border_color = a.opt_str("border_color")?.map(parse_color).transpose()?; + if border_color.is_some() && border <= 0.0 { + return Err(ToolError::InvalidArgs("border_color needs a border".into())); + } if let Some(path) = a.opt_str("path")? { if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() { return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into())); @@ -581,8 +593,9 @@ impl Automation { let w = (longest + 8.0).clamp(20.0, 600.0); let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1); let h = rows as f64 * size * 1.2 + 6.0; - let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); - Shape::Typewriter { rect, font_size: size } + let [x0, y0, x1, y1] = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); + // A border goes around the box, so the text sits where it would without one. + Shape::Typewriter { rect: [x0 - border, y0 - border, x1 + border, y1 + border], font_size: size } }; let (shape, contents) = match kind { "text" => { @@ -628,6 +641,10 @@ impl Automation { if let Some(color) = requested_color { style.color = color; } + if matches!(shape, Shape::Typewriter { .. }) { + style.width = border; + style.border_color = border_color; + } self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author })) } @@ -694,6 +711,10 @@ impl Automation { let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) }; edits.push(Edit::FillAnnotation { page, index, fill }); } + if let Some(c) = a.opt_str("border_color")? { + let color = if c.eq_ignore_ascii_case("text") { None } else { Some(parse_color(c)?) }; + edits.push(Edit::BorderColorAnnotation { page, index, color }); + } // After `opacity`, which sets the whole comment's. let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?); if fill_opacity.into_iter().chain(text_opacity).any(|o| !(0.0..=1.0).contains(&o)) { @@ -735,7 +756,7 @@ impl Automation { } if edits.is_empty() { return Err(ToolError::InvalidArgs( - "nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, font_size, rect, move or points".into(), + "nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, border_color, font_size, rect, move or points".into(), )); } let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } }; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index b3b149fbc2..d15c1d915f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -994,7 +994,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }), &["doc"], )), - t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( + t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( comment_ref(json!({ "contents": { "type": "string" }, "color": color(), @@ -1003,6 +1003,7 @@ pub fn tools() -> Vec { "text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity (text box, typewriter text, callout)." }, "width": { "type": "number", "minimum": 0 }, "fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." }, + "border_color": { "type": "string", "description": "A text box's, typewriter text's or callout's border colour: #RRGGBB, a colour name, or \"text\" for the text's colour." }, "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "move": point(), @@ -1407,7 +1408,7 @@ pub fn tools() -> Vec { t( "fill_sign_add", "Fill & Sign: type text or place a mark", - "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", + "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", ) .cmd("sign.fill.text") .with(schema( @@ -1419,6 +1420,8 @@ pub fn tools() -> Vec { "text": { "type": "string", "minLength": 1 }, "font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." }, "color": color(), + "border": { "type": "number", "minimum": 0, "maximum": 12, "description": "For type text or date: a border around it in points, in the text's colour (default 0, none)." }, + "border_color": { "type": "string", "description": "With border: its own colour, #RRGGBB or a colour name." }, "path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." }, "scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." }, "sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index f0426fa3fc..a4ee239a62 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4074,6 +4074,48 @@ fn fill_sign_text_and_date_colours_are_saved_and_validated() { } } +#[test] +fn fill_sign_text_border_is_saved_and_validated() { + let dir = workdir("fill-sign-text-border"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": "Plain" })); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 80], "text": "Plain", "border": 1.5 })); + let comments = ok(&mut a, "comment_list", json!({ "doc": doc })); + let width = |k: usize| { + let rect = comments["comments"][k]["rect"].as_array().unwrap(); + rect[2].as_f64().unwrap() - rect[0].as_f64().unwrap() + }; + assert!((width(1) - width(0) - 3.0).abs() < 1e-6, "1.5 pt on each side: {comments}"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "framed.pdf" })); + let saved = std::fs::read(dir.join("framed.pdf")).unwrap(); + let pdf = pdfcraft_cos::Document::open(std::sync::Arc::new(saved)).unwrap(); + let page = pdfcraft_model::pages(&pdf).remove(0); + let annotations = pdf.resolve(page.dict.get(b"Annots").unwrap()); + let annotation = pdf.resolve(annotations.as_array().unwrap().get(1).unwrap()); + let bs = annotation.as_dict().unwrap().get(b"BS").unwrap().as_dict().unwrap().get(b"W").unwrap().as_f64().unwrap(); + assert_eq!(bs, 1.5, "saved annotation has the requested border"); + // Its own colour, then the text's again through comment_edit. + ok( + &mut a, + "fill_sign_add", + json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 160], "text": "Red", "border": 1, "border_color": "#ff0000" }), + ); + let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + let red = list["comments"].as_array().unwrap().iter().find(|c| c["contents"] == "Red").unwrap()["index"].clone(); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": red, "border_color": "text" })); + assert!( + a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 200], "text": "No", "border_color": "red" })).is_err(), + "no border" + ); + for (kind, border) in [("text", -1.0), ("text", 13.0), ("check", 1.0)] { + assert!(matches!( + a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": kind, "at": [20, 120], "text": "No", "border": border })), + Err(ToolError::InvalidArgs(_)) + )); + } +} + #[test] fn image_signatures_through_tools_preserve_transparency_and_survive_save() { let dir = workdir("image-signatures"); diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index e398604766..ab3393a0f5 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -278,6 +278,13 @@ pub enum Edit { index: usize, size: f64, }, + /// Give a text box, typewriter text or callout a border colour of its own (`None`: the + /// text's again). + BorderColorAnnotation { + page: usize, + index: usize, + color: Option, + }, /// Comment properties ▸ General / note icon. SetAnnotationInfo { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 0c7046cdbb..a244d70c93 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -236,6 +236,7 @@ impl Edit { | Edit::FillAnnotation { .. } | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } + | Edit::BorderColorAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"), Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 27eee7e428..3d24a22fa2 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -67,6 +67,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::FillAnnotation { .. } | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } + | Edit::BorderColorAnnotation { .. } | Edit::SetAnnotationInfo { .. } | Edit::SetMeasurementScale { .. } => { if p.annotate() { @@ -587,6 +588,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, Edit::PartOpacity { page, index, fill, text } => pdfcraft_annot::set_part_opacity(doc, *page, *index, *fill, *text, &cx.meta())?, Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, + Edit::BorderColorAnnotation { page, index, color } => pdfcraft_annot::set_border_color(doc, *page, *index, *color, &cx.meta())?, Edit::SetAnnotationInfo { page, index, author, subject, icon } => { pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; } diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 643f1d66a3..c679406154 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -71,6 +71,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::FillAnnotation { .. } | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } + | Edit::BorderColorAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Scope::Comments, Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 9dd3efac6e..7c7710b5b8 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -110,6 +110,9 @@ impl PdfCraftApp { fill_text_size: fill_sign::TEXT_SIZE, fill_tool: fill_sign::FillTool::Signature, fill_text_color: [0.0; 3], + fill_text_border: 0.0, + fill_text_border_color: None, + fill_text_default: fill_sign::TextBorder::NONE, dialog: None, updates: updates::Updates::default(), printer_source: None, diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 5f9297c2ab..ee33caeb5c 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -103,6 +103,8 @@ impl PdfCraftApp { "ui_scale": self.ui_scale, "flatten_fill_sign": self.flatten_fill_sign_on_save, "fill_text_size": self.fill_text_size, + "fill_text_border": self.fill_text_default.width, + "fill_text_border_color": self.fill_text_default.color, "date_format": self.session.date_format(), // Null follows the interface language. "date_language": self.session.date_language(), @@ -120,6 +122,7 @@ impl PdfCraftApp { "text_opacity": s.text_opacity, "width": s.width, "fill": s.fill, + "border_color": s.border_color, })) .collect::>(), // Drawn signatures keep their original form (older settings read the same). @@ -310,6 +313,22 @@ impl PdfCraftApp { if let Some(show) = v["show_page_edge_shadows"].as_bool() { self.show_page_edge_shadows = show; } + if let Some(width) = v["fill_text_border"].as_f64().and_then(fill_sign::valid_text_border) { + self.fill_text_default.width = width; + } + // `null` is a saved "the text's colour"; a missing or malformed one keeps the setting. + match v.get("fill_text_border_color") { + Some(serde_json::Value::Null) => self.fill_text_default.color = None, + Some(c) => { + if let Some(c) = rgb_triple(c) { + self.fill_text_default.color = Some(c.map(|x| x.clamp(0.0, 1.0))); + } + } + None => {} + } + // Fill & Sign text starts from the saved border. + self.fill_text_border = self.fill_text_default.width; + self.fill_text_border_color = self.fill_text_default.color; if let Some(prefs) = RedactPrefs::from_json(&v["redact_prefs"]) { self.redact_prefs = prefs.clone(); self.redact_defaults = prefs; @@ -375,6 +394,17 @@ impl PdfCraftApp { s.fill = fill; self.comment_prefs.set_style(tool, s); } + // `"border_color": null` is a saved "Same as text", which clears a tool's own border + // colour; a missing or malformed one keeps it. + match e.get("border_color") { + Some(serde_json::Value::Null) => self.comment_prefs.set_border_color(tool, None), + Some(c) => { + if let Some(c) = rgb_triple(c) { + self.comment_prefs.set_border_color(tool, Some(c)); + } + } + None => {} + } } } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2207adbf59..9681126048 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1214,6 +1214,7 @@ struct PageEnv<'a> { y_shift: f32, fill_text_size: f64, fill_text_color: [f64; 3], + fill_text_border: crate::fill_sign::TextBorder, today: (i64, u32, u32), can_paste_image: bool, } @@ -3160,6 +3161,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { y_shift, fill_text_size: app.fill_text_size, fill_text_color: app.fill_text_color, + fill_text_border: crate::fill_sign::TextBorder { width: app.fill_text_border, color: app.fill_text_border_color }, today, can_paste_image, }; diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index d366967b9f..e1c9719668 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -266,6 +266,7 @@ fn tool_input( units, fill_text_size, fill_text_color, + fill_text_border, .. } = frame.env; let PageSlot { i, xf, .. } = *slot; @@ -295,6 +296,7 @@ fn tool_input( tool: ft, text_size: fill_text_size, text_color: fill_text_color, + text_border: fill_text_border, signature: signature.as_ref(), initials: initials.as_ref(), preview: frame.signature_preview, diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 285423cbca..e3762ce0c0 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -102,6 +102,22 @@ impl PdfCraftApp { let Some((_, id)) = self.active_ids() else { return }; let Some(doc) = self.session.get(id) else { return }; let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && !a.is_reply()) else { return }; + // Typed text has no comment tool: Fill & Sign gives new text its size, colour and border, + // kept across launches. + if crate::comments::typed_text(a) { + let kind = crate::i18n::in_sentence(tl!(crate::comments_panel::kind_label(a))).to_string(); + if let Some(size) = p.font_size.and_then(crate::fill_sign::valid_text_size) { + self.fill_text_size = size; + } + if let Some(color) = p.color.and_then(crate::fill_sign::valid_text_color) { + self.fill_text_color = color; + } + let width = p.width.and_then(crate::fill_sign::valid_text_border).unwrap_or(0.0); + self.fill_text_default = crate::fill_sign::TextBorder { width, color: p.border_color }; + (self.fill_text_border, self.fill_text_border_color) = (width, p.border_color); + self.notify_fmt("New {tool} comments will look like this one", &[("tool", &kind)]); + return; + } let Some(tool) = crate::comments::tool_for_default(a, p.endings.as_deref()) else { return }; let mut style = self.comment_prefs.style(tool); if let Some(c) = p.color { @@ -111,6 +127,8 @@ impl PdfCraftApp { style.fill = p.fill; } self.comment_prefs.set_style(tool, style); + // A text box's or callout's own border colour (cleared for tools that have none). + self.comment_prefs.set_border_color(tool, p.border_color); // The fill's and text's own opacities where they show (else the opacity is the whole // comment's). With neither a border nor a fill the text is the whole comment: its drawn // opacity, which Comment Properties shows, is the whole comment's default. @@ -249,6 +267,9 @@ fn changes(o: &CommentProps, e: &CommentProps, page: usize, index: usize, t: &Co { out.push(Edit::FontSizeAnnotation { page, index, size }); } + if t.restylable && t.font_size.is_some() && e.border_color != o.border_color { + out.push(Edit::BorderColorAnnotation { page, index, color: e.border_color }); + } let author = (e.author != o.author).then(|| e.author.clone()); let subject = (e.subject != o.subject).then(|| e.subject.clone()); let icon = (e.icon != o.icon).then_some(e.icon).flatten().filter(|_| t.icon.is_some()); @@ -273,11 +294,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo .collect() }) .unwrap_or_default(); - let default_tool = app.comment_props.as_ref().and_then(|d| { - let (_, id) = app.active_ids()?; - let doc = app.session.get(id)?; - let a = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply())?; - crate::comments::tool_for_default(a, d.edited.endings.as_deref()) + let has_default = app.comment_props.as_ref().is_some_and(|d| { + let doc = app.active_ids().and_then(|(_, id)| app.session.get(id)); + doc.and_then(|doc| doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply())) + .is_some_and(|a| crate::comments::tool_for_default(a, d.edited.endings.as_deref()).is_some() || crate::comments::typed_text(a)) }); let Some(d) = app.comment_props.as_mut() else { return (false, true) }; let kind = tl!(crate::comments_panel::subtype_label(&d.edited.subtype)).to_string(); @@ -320,6 +340,17 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo e.color = Some(c); } ui.end_row(); + // Text boxes, typewriter text and callouts: the border's own colour. + if e.font_size.is_some() { + ui.label(tl!("Border colour")); + let text = e.color.unwrap_or([0.0; 3]); + ui.horizontal(|ui| { + if let Some(c) = crate::comments::border_colour_button(ui, e.border_color, text) { + e.border_color = c; + } + }); + ui.end_row(); + } if e.fillable { ui.label(tl!("Fill colour")); if let Some(f) = crate::comments::fill_picker(ui, e.fill) { @@ -428,8 +459,9 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.horizontal(|ui| { ui.add(widgets::checkbox(&mut d.edited.locked, tl!("Locked"))); ui.add_space(16.0); - // Acrobat's box beside Locked. Only a comment whose look can change, made by a tool. - let defaultable = d.edited.restylable && default_tool.is_some(); + // Acrobat's box beside Locked. Only a comment whose look can change, made by a tool (or + // typed text, for Fill & Sign). + let defaultable = d.edited.restylable && has_default; ui.add_enabled(defaultable, widgets::checkbox(&mut d.make_default, tl!("Make Properties Default"))); }); let (mut apply, mut cancel) = (false, false); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6a788e765d..e5e2d49b0d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -810,7 +810,8 @@ impl PdfCraftApp { } let author = self.comment_prefs.author.clone(); let rotation = self.session.get(id).map_or(0, |doc| crate::comments::page_rotation(&doc.info, tb.page)); - let edit = crate::fill_sign::typed_with_color(tb.page, tb.at, &text, tb.size, rotation, &author, tb.color); + let look = crate::fill_sign::TypedLook { color: tb.color, border: tb.border }; + let edit = crate::fill_sign::typed_with(tb.page, tb.at, &text, tb.size, rotation, &author, look); if self.apply_edit(edit) { if let Some(view) = self.views.get_mut(i) { view.fill_text = None; @@ -929,6 +930,7 @@ fn comment_page(edit: &Edit) -> Option { | Edit::FillAnnotation { page, .. } | Edit::PartOpacity { page, .. } | Edit::FontSizeAnnotation { page, .. } + | Edit::BorderColorAnnotation { page, .. } | Edit::SetAnnotationInfo { page, .. } => Some(*page), _ => None, } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index f27b1e035b..fafae309ff 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -323,6 +323,62 @@ fn selected_fill_text(app: &PdfCraftApp) -> Option<(usize, usize)> { .then_some((page, annotation)) } +/// Fill & Sign's Border controls for typed text: its width and colour, for the selected text +/// (`picked`), or for new text when none is selected. The label shows in a vertical bar, as the +/// font size's does. +fn fill_text_border_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, picked: Option<(usize, usize)>, vertical: bool) { + // The selected text's own border (Comment Properties may have changed it + // since): the controls below show it and change only it. Without one they + // set what new text gets until PdfCraft quits; Make Current Properties + // Default on typed text keeps it. + let selected = picked.and_then(|(page, annotation)| { + let p = app.active_ids().and_then(|(_, id)| app.session.get(id))?.comment_props(page, annotation)?; + Some((page, annotation, crate::fill_sign::TextBorder { width: p.width.unwrap_or(0.0), color: p.border_color })) + }); + let was = selected.map_or(crate::fill_sign::TextBorder { width: app.fill_text_border, color: app.fill_text_border_color }, |(_, _, own)| own); + let mut border = was; + // A border around the text, in its colour; the text keeps its place. + let border_label = |w: f64| if w > 0.0 { format!("{w} pt") } else { tl!("None").to_string() }; + let label = vertical.then(|| ui.label(tl!("Border:"))); + let combo = craft_ui::ComboBox::from_id_salt("fill-text-border") + .selected_text(border_label(was.width)) + .width(72.0) + .show_ui(ui, |ui| { + for w in crate::fill_sign::TEXT_BORDERS { + ui.selectable_value(&mut border.width, w, border_label(w)); + } + }) + .response; + match label { + Some(label) => { + combo.labelled_by(label.id); + } + None => { + combo.on_hover_text(tl!("Border:")); + } + } + // The border's own colour (the text's unless picked). + if was.width > 0.0 + && let Some(color) = comments::border_colour_button(ui, was.color, [0.0; 3]) + { + border.color = color; + } + match selected { + Some((page, index, _)) => { + if border.width != was.width { + app.apply_edit(Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(border.width), endings: None }); + } + if border.color != was.color { + app.apply_edit(Edit::BorderColorAnnotation { page, index, color: border.color }); + } + } + None => { + app.fill_text_border = border.width; + app.fill_text_border_color = border.color; + } + } +} + /// Extra controls rendered below/beside the buttons when a comment or fill tool is active. fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, vertical: bool) { let current_fill = match app.quick_tool { @@ -365,9 +421,28 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve { app.apply_edit(Edit::FontSizeAnnotation { page, index, size }); } + // After the text's own controls, so they keep their place in a bar that scrolls. + fill_text_border_controls(ui, app, selected_fill_text(app), vertical); } if let QuickTool::Comment(tool) = app.quick_tool { controls_divider(ui, t, vertical); + // Text boxes and callouts: the border's own colour, for new ones and the selected one + // this tool made (not a locked one). + if tool.has_border_color() { + let style = app.comment_prefs.style(tool); + if let Some(color) = comments::border_colour_button(ui, style.border_color, style.color) { + app.comment_prefs.set_border_color(tool, color); + if let Some((index, id)) = app.active_ids() + && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) + && app.session.get(id).is_some_and(|doc| { + doc.allows_annotation() + && doc.info.annotations.iter().any(|a| a.page == page && a.index == annotation && !a.locked && comments::made_by(a, tool)) + }) + { + app.apply_edit(Edit::BorderColorAnnotation { page, index: annotation, color }); + } + } + } // The selected comments this tool made take the colour too (locked ones keep theirs). if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs) && let Some((_, id)) = app.active_ids() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5b6465f1df..0cb7237376 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -337,6 +337,14 @@ pub struct PdfCraftApp { pub fill_tool: fill_sign::FillTool, /// Colour for new Fill & Sign text and dates, kept across launches. pub fill_text_color: [f64; 3], + /// Border around new Fill & Sign text and dates, in points (0: none), until PdfCraft quits: + /// each launch starts from [`Self::fill_text_default`]. + pub fill_text_border: f64, + /// That border's own colour (`None`: the text's). + pub fill_text_border_color: Option, + /// The border new Fill & Sign text starts each launch with, kept across launches: what Make + /// Current Properties Default last took from typed text (none at first). + pub fill_text_default: fill_sign::TextBorder, pub dialog: Option, /// How to ask for the latest release (the desktop app sets it; see `updates`). pub(crate) updates: updates::Updates, diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index 0ac648d59c..430bdbec85 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -616,7 +616,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti view.comments.props_request = Some((a.page, a.index)); ui.close(); } - if ui.add_enabled(crate::comments::tool_for(a).is_some(), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { + if ui.add_enabled(crate::comments::defaultable(a), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { view.comments.default_request = Some((a.page, a.index)); ui.close(); } diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 7e6d88b520..451e5e4000 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -39,7 +39,7 @@ pub use geometry::{drawn_text_box_rect, text_box_rect}; use input::hover_tip; pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; -pub(crate) use menu::{MenuRights, attachment_items, context_menu, quick_bar_colour, quick_bar_controls}; +pub(crate) use menu::{MenuRights, attachment_items, border_colour_button, context_menu, quick_bar_colour, quick_bar_controls}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; pub(crate) use paint::paint_page; @@ -247,6 +247,12 @@ impl CommentTool { if matches!(self, Self::TextBox | Self::Callout) { 0.0 } else { 0.5 } } + /// Whether the border-colour control applies to this tool: text boxes and callouts, whose + /// border takes the text colour unless it has its own. + pub fn has_border_color(self) -> bool { + matches!(self, Self::TextBox | Self::Callout) + } + /// Whether the fill-colour control applies to this tool. pub fn has_fill(self) -> bool { matches!(self, Self::Rectangle | Self::Oval | Self::Polygon | Self::Cloud | Self::TextBox | Self::Callout) @@ -369,6 +375,14 @@ impl CommentPrefs { } } + /// The border's own colour for new text boxes and callouts (`None`: the text colour). + pub fn set_border_color(&mut self, tool: CommentTool, color: Option) { + if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { + s.border_color = + if tool.has_border_color() { color.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0))) } else { None }; + } + } + pub fn set_style(&mut self, tool: CommentTool, style: Style) { if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { *s = style; @@ -400,6 +414,17 @@ fn arrow_ending(ending: LineEnding) -> bool { matches!(ending, LineEnding::OpenArrow | LineEnding::ClosedArrow | LineEnding::ROpenArrow | LineEnding::RClosedArrow) } +/// Typed text (Fill & Sign's, or a typewriter's): no comment tool makes it, so Make Current +/// Properties Default sets what Fill & Sign gives new text. +pub(crate) fn typed_text(a: &Annotation) -> bool { + a.subtype == "FreeText" && a.intent.as_deref() == Some("FreeTextTypeWriter") +} + +/// Whether Make Current Properties Default has defaults to set from `a`. +pub(crate) fn defaultable(a: &Annotation) -> bool { + tool_for(a).is_some() || typed_text(a) +} + /// The tool that makes comments like `a` (for Make Current Properties Default). pub fn tool_for(a: &Annotation) -> Option { Some(match (a.subtype.as_str(), a.intent.as_deref()) { diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index ecbe36d6ae..a2c38b2628 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -3,7 +3,7 @@ use super::paint::edit_anchor; use super::state::{Composer, ComposerKind, Gesture}; use super::{ Annotation, Color32, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Rect, ReviewState, Rgb, SELECT_BLUE, SWATCHES, Sense, Stroke, color32, - icons, theme, tool_for, vec2, + defaultable, icons, theme, vec2, }; /// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's @@ -153,7 +153,7 @@ pub(crate) fn context_menu( action = Some(CanvasAction::Properties(page, index)); ui.close(); } - if ui.add_enabled(tool_for(a).is_some(), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { + if ui.add_enabled(defaultable(a), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { view.comments.default_request = Some((page, index)); ui.close(); } @@ -400,6 +400,33 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m selected_color } +/// A border-colour button: a hollow square in the border's colour (`current`, else the text's, +/// `text`), with a menu of "Same as text" and the swatches. Returns the choice (`Some(None)`: +/// the text's colour again). +pub(crate) fn border_colour_button(ui: &mut egui::Ui, current: Option, text: Rgb) -> Option> { + let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); + let sq = Rect::from_center_size(r.center(), vec2(15.0, 15.0)); + ui.painter().rect_stroke(sq, 1.0, Stroke::new(3.0, color32(current.unwrap_or(text))), egui::StrokeKind::Inside); + if resp.hovered() { + ui.painter().rect_stroke(r.shrink(2.0), 4.0, Stroke::new(1.0, Color32::from_black_alpha(40)), egui::StrokeKind::Inside); + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Border colour"))); + let resp = resp.on_hover_text(tl!("Border colour")); + let mut picked = None; + egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).show(|ui| { + ui.label(egui::RichText::new(tl!("Border colour")).font(theme::semibold(12.0))); + if ui.add(egui::Button::new(tl!("Same as text")).selected(current.is_none())).clicked() { + picked = Some(None); + ui.close(); + } + if let Some(c) = swatch_grid(ui, current) { + picked = Some(Some(c)); + ui.close(); + } + }); + picked +} + /// The quick bar's fill button: a filled square in the fill colour, or a crossed-out outline /// for no fill. fn fill_button(ui: &mut egui::Ui, fill: Option) -> egui::Response { diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index af64f63adf..7e98eda05b 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -6,7 +6,7 @@ use crate::file_filter::AddFilterAnyCase as _; use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2}; use pdfcraft_engine::signature_scan::{DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, SCAN_MAX_SIDE, ScanError}; -use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, SignatureImage, SignaturePhoto, Style, Turn}; +use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Rgb, Shape, SignatureImage, SignaturePhoto, Style, Turn}; use pdfcraft_render::{DocInfo, PageInfo}; use crate::canvas::{DocView, PageToolContext, PageXform}; @@ -215,9 +215,23 @@ pub struct TypeBox { pub text: String, pub size: f64, pub color: [f64; 3], + /// The border around the text. + pub border: TextBorder, pub focus: bool, } +/// The border around typed text: its width in points (0: none) and its own colour (`None`: the +/// text's). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TextBorder { + pub width: f64, + pub color: Option, +} + +impl TextBorder { + pub const NONE: Self = Self { width: 0.0, color: None }; +} + /// The default size Fill & Sign uses for typed text. pub const TEXT_SIZE: f64 = 10.0; pub const MIN_TEXT_SIZE: f64 = 4.0; @@ -231,6 +245,14 @@ pub fn valid_text_color(color: [f64; 3]) -> Option<[f64; 3]> { color.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)).then_some(color) } +/// The borders the toolbar offers around typed text, in points (0: none). +pub const TEXT_BORDERS: [f64; 6] = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0]; +pub const MAX_TEXT_BORDER: f64 = 12.0; + +pub fn valid_text_border(width: f64) -> Option { + (width.is_finite() && (0.0..=MAX_TEXT_BORDER).contains(&width)).then_some(width) +} + fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { let (vx, vy) = xf.screen_to_view(p); let u = info.pages[page].view_to_user(vx, vy); @@ -243,17 +265,30 @@ fn new(page: usize, shape: Shape, contents: String, author: &str) -> Edit { } /// A typewriter annotation sized to its text, its displayed top-left at `at` on a page turned by -/// `rotation` (its `/Rotate`). +/// `rotation` (its `/Rotate`), in black and without a border. pub fn typed(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str) -> Edit { - typed_with_color(page, at, text, size, rotation, author, [0.0; 3]) + typed_with(page, at, text, size, rotation, author, TypedLook::default()) } -/// Typed text/date colour applies to its annotation appearance and survives saving. -pub fn typed_with_color(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str, color: [f64; 3]) -> Edit { - let rect = crate::comments::text_box_rect(at, text, size, rotation); - let shape = Shape::Typewriter { rect, font_size: size }; - let mut style = Style::default_for(&shape); - style.color = valid_text_color(color).unwrap_or([0.0; 3]); +/// How typed text and dates look: their colour, and the border around them. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TypedLook { + pub color: [f64; 3], + pub border: TextBorder, +} + +/// [`typed`] in `look`: the colour applies to the annotation's appearance and survives saving; a +/// border goes around the box sized to the text, so the text sits where it would without one. +pub fn typed_with(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str, look: TypedLook) -> Edit { + let width = valid_text_border(look.border.width).unwrap_or(0.0); + let [x0, y0, x1, y1] = crate::comments::text_box_rect(at, text, size, rotation); + let shape = Shape::Typewriter { rect: [x0 - width, y0 - width, x1 + width, y1 + width], font_size: size }; + let style = Style { + color: valid_text_color(look.color).unwrap_or([0.0; 3]), + width, + border_color: look.border.color.filter(|_| width > 0.0), + ..Style::default_for(&shape) + }; Edit::AddAnnotation(NewAnnotation { page, shape, style, contents: text.to_string(), author: author.to_string() }) } @@ -453,6 +488,7 @@ pub(crate) struct PageInput<'a> { pub(crate) tool: FillTool, pub(crate) text_size: f64, pub(crate) text_color: [f64; 3], + pub(crate) text_border: TextBorder, pub(crate) signature: Option<&'a SavedSig>, pub(crate) initials: Option<&'a SavedSig>, pub(crate) preview: &'a mut Option<(SavedSig, egui::TextureHandle)>, @@ -463,7 +499,7 @@ pub(crate) struct PageInput<'a> { /// Preview and clicks with a Fill & Sign tool on one page. pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bool, view: &mut DocView) -> Option { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; - let PageInput { tool, text_size, text_color, signature, initials, preview, author, date_text } = input; + let PageInput { tool, text_size, text_color, text_border, signature, initials, preview, author, date_text } = input; let pointer = ptr.within(xf.rect)?; let p = info.pages.get(page)?; let at = to_user(xf, info, page, pointer); @@ -495,21 +531,37 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bo // deselects, as a comment tool's note or text box does: Escape then closes the box and // leaves the tool in two presses. view.comments.clear_selection(); - let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, color: text_color, focus: true }); + let open = view.fill_text.replace(TypeBox { + page, + at: top, + text: String::new(), + size: text_size, + color: text_color, + border: text_border, + focus: true, + }); open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| { - FillAction::Edit(Box::new(typed_with_color( + FillAction::Edit(Box::new(typed_with( tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, - tb.color, + TypedLook { color: tb.color, border: tb.border }, ))) }) } FillTool::Date => Some(match date_text { - Ok(text) => FillAction::Edit(Box::new(typed_with_color(page, top, text, text_size, rotation, author, text_color))), + Ok(text) => FillAction::Edit(Box::new(typed_with( + page, + top, + text, + text_size, + rotation, + author, + TypedLook { color: text_color, border: text_border }, + ))), Err(why) => FillAction::Refused(why.clone()), }), FillTool::Signature => match signature { @@ -586,6 +638,12 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, .id_salt("fill-text-edit"), ) .accessible_name(tl!("Text to place")); + // The border the text will get, around the box being typed into. + if t.border.width > 0.0 { + let w = (t.border.width as f32 * zoom).max(1.0); + let color = t.border.color.map_or(Color32::BLACK, crate::comments::color32); + ui.painter().rect_stroke(r.rect, 0.0, Stroke::new(w, color), egui::StrokeKind::Outside); + } if t.focus { r.request_focus(); t.focus = false; @@ -604,7 +662,8 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, if commit { let tb = view.fill_text.take()?; if !tb.text.trim().is_empty() { - return Some(typed_with_color(tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, tb.color)); + let look = TypedLook { color: tb.color, border: tb.border }; + return Some(typed_with(tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, look)); } } None diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index cff01c0014..0396714c18 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2382,6 +2382,36 @@ fn a_text_only_text_box_made_default_gives_the_tool_its_texts_opacity() { assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h)); } +/// Make Properties Default on a text box with a border colour of its own gives the Text box tool +/// that colour. +#[test] +fn a_text_box_made_default_gives_the_tool_its_border_colour() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 }; + let style = Style { width: 1.0, ..Style::default_for(&shape) }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "Framed".into(), + author: "Tester".into() + }))); + h.run_steps(2); + let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Framed")).map(|a| a.index).expect("the text box"); + let red = Some([0.89, 0.13, 0.13]); + assert!(h.state_mut().apply_edit(Edit::BorderColorAnnotation { page: 0, index, color: red })); + assert_ne!(h.state().comment_prefs.style(CommentTool::TextBox).border_color, red); + h.state_mut().open_comment_props(0, index); + h.run_steps(2); + h.get_by_label("Make Properties Default").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().comment_prefs.style(CommentTool::TextBox).border_color, red); +} + /// #1302: making an arrow's properties the default updates the Arrow tool, not later plain lines. #[test] fn an_arrows_default_does_not_restyle_lines() { @@ -3130,6 +3160,7 @@ fn a_moved_comment_is_drawn_where_it_goes_before_release() { fill: Some([1.0, 0.0, 0.0]), fill_opacity: None, text_opacity: None, + border_color: None, }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page: 0, diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 04f67ce216..1b9cb85a39 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -1801,6 +1801,7 @@ fn control_quit_retains_inactive_fill_sign_typing_after_cancel() { text: "Unsaved signature note".into(), size: 10.0, color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, focus: false, }); h.state_mut().active = None; diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index b5ad9f0522..55b6bdafd9 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -292,6 +292,50 @@ fn fill_text_size_resizes_the_selected_text() { assert!(after > before * 2.0, "36 pt text has a taller box than 10 pt text: {before} -> {after}"); } +/// Dragging the Font size control with a placed text selected resizes it once, when the drag +/// ends, to the size it was dragged to: the frame of the release must not put the old size back. +#[test] +fn dragging_the_font_size_resizes_the_selected_text_to_where_the_drag_ends() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("Drag me".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (id, annotation) = { + let s = h.state(); + let id = s.views[0].id; + let a = s.session.get(id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Drag me")).map(|a| (a.page, a.index)); + (id, a.unwrap()) + }; + h.state_mut().views[0].comments.selected = Some(annotation); + h.run_steps(2); + // The size is set in the Font size button's popup. + h.get_by_role_and_label(egui::accesskit::Role::Button, "Font size").click(); + h.run_steps(2); + let from = h + .query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.value().is_some_and(|v| v.ends_with(" pt"))) + .expect("the Font size control") + .rect() + .center(); + let before = h.state().fill_text_size; + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for dx in [10.0, 20.0, 30.0, 40.0] { + h.hover_at(from + egui::vec2(dx, 0.0)); + h.run_steps(1); + } + h.drop_at(from + egui::vec2(40.0, 0.0)); + h.run_steps(4); + let dragged = h.state().fill_text_size; + assert!(dragged > before, "the drag made the size larger: {before} -> {dragged}"); + let placed = h.state().session.get(id).unwrap().comment_props(annotation.0, annotation.1).and_then(|p| p.font_size); + assert_eq!(placed, Some(dragged), "the selected text has the size the drag ended at"); +} + /// The font sizes of the texts `text` drawn in the last frame. fn drawn_sizes(h: &Harness<'static, PdfCraftApp>, text: &str) -> Vec { fn walk(shape: &egui::Shape, text: &str, out: &mut Vec) { @@ -337,6 +381,150 @@ fn the_type_box_text_matches_the_page_zoom_in_a_turned_view() { } } +/// With a placed text selected, the toolbar shows its own border, also after Comment Properties +/// changed it, and changes only that text: the Border colour button gives its border its own +/// colour ("Same as text" takes it back), and new text keeps the toolbar's own border. +#[test] +fn the_toolbar_shows_and_changes_the_selected_texts_border() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("Framed".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (page, index) = { + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Framed")).cloned().unwrap(); + (a.page, a.index) + }; + let props = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + let shows = |h: &Harness<'static, PdfCraftApp>, value: &str| { + h.query_all_by_role(egui::accesskit::Role::ComboBox).any(|n| n.value().as_deref() == Some(value)) + }; + h.state_mut().views[0].comments.selected = Some((page, index)); + // As Comment Properties ▸ Thickness does. + h.state_mut().apply_edit(pdfcraft_engine::Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(2.0), endings: None }); + h.run_steps(3); + assert!(shows(&h, "2 pt"), "the toolbar shows the selected text's border"); + assert_eq!(h.state().fill_text_border, 0.0, "new text keeps none"); + + h.get_by_label("Border colour").click(); + h.run_steps(2); + h.get_by_label("Red").click(); + h.run_steps(4); + let red = Some([0.89, 0.13, 0.13]); + assert_eq!(props(&h).border_color, red, "the selected text's border is red"); + assert_eq!(h.state().fill_text_border_color, None, "new text's border keeps the text's colour"); + h.get_by_label("Border colour").click(); + h.run_steps(2); + h.get_by_label("Same as text").click(); + h.run_steps(4); + assert_eq!(props(&h).border_color, None); + // Nothing selected, the toolbar shows new text's border again. + h.state_mut().views[0].comments.selected = None; + h.run_steps(2); + assert!(shows(&h, "None")); +} + +/// The Border control puts a frame around new Fill & Sign text, and around the text that is +/// selected; the box grows outwards, so the text stays where it was typed. The choice persists. +#[test] +fn fill_text_border_frames_new_and_selected_text() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + h.run_steps(2); + h.get_by_label("Border:"); + let type_at = |h: &mut Harness<'static, PdfCraftApp>, y: f32| { + click(h, 50.0, y); + h.event(egui::Event::Text("Same".into())); + h.run_steps(1); + commit_typed_text(h); + }; + // The two texts, oldest first: (page, index, rect). + let texts = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let mut v: Vec<_> = + doc.info.annotations.iter().filter(|a| a.contents.as_deref() == Some("Same")).map(|a| (a.page, a.index, a.rect)).collect(); + v.sort_by_key(|t| t.1); + v + }; + let width = |r: [f32; 4]| r[2] - r[0]; + type_at(&mut h, 150.0); + // Nothing selected, so the toolbar sets new text's border only (it shows a selected text's). + h.state_mut().views[0].comments.selected = None; + h.state_mut().fill_text_border = 1.5; + type_at(&mut h, 250.0); + let [(page, index, plain), (_, _, framed)] = texts(&h)[..] else { panic!("two texts") }; + assert!((width(framed) - width(plain) - 3.0).abs() < 0.01, "1.5 pt on each side: {plain:?} {framed:?}"); + + // The plain text selected, then framed with 2 pt from the toolbar. + h.state_mut().views[0].comments.selected = Some((page, index)); + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.value().as_deref() == Some("1.5 pt")) + .expect("the Border control, showing the border for new text") + .click(); + h.run_steps(2); + h.get_by_label("2 pt").click(); + h.run_steps(4); + assert_eq!(h.state().fill_text_border, 1.5, "new text keeps its own"); + let (_, _, now) = texts(&h)[0]; + assert!((width(now) - width(plain) - 4.0).abs() < 0.01, "2 pt on each side: {plain:?} {now:?}"); + assert!((plain[0] - now[0] - 2.0).abs() < 0.01 && (now[3] - plain[3] - 2.0).abs() < 0.01, "grown outwards, the text in place: {plain:?} {now:?}"); + + // The toolbar's border lasts until PdfCraft quits; a saved default is read back. + let settings = h.state().persist(); + let mut restored = PdfCraftApp::new(); + restored.restore(&settings); + assert_eq!(restored.fill_text_border, 0.0); + restored.restore(r#"{"fill_text_border":2}"#); + assert_eq!(restored.fill_text_border, 2.0); + restored.restore(r#"{"fill_text_border":100}"#); + assert_eq!(restored.fill_text_border, 2.0, "invalid settings do not change the border"); +} + +/// Make Properties Default on typed text sets what Fill & Sign gives new text, its size and +/// border, and keeps it across launches. +#[test] +fn typed_text_made_default_is_what_new_text_gets() { + use pdfcraft_engine::Edit; + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + let type_at = |h: &mut Harness<'static, PdfCraftApp>, y: f32, text: &str| { + click(h, 50.0, y); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + commit_typed_text(h); + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some(text)).cloned().unwrap(); + (a.page, a.index) + }; + let (page, index) = type_at(&mut h, 150.0, "Model"); + let red = Some([0.89, 0.13, 0.13]); + for edit in [ + Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(1.5), endings: None }, + Edit::BorderColorAnnotation { page, index, color: red }, + Edit::FontSizeAnnotation { page, index, size: 14.0 }, + ] { + assert!(h.state_mut().apply_edit(edit)); + } + h.state_mut().open_comment_props(page, index); + h.run_steps(2); + h.get_by_label("Make Properties Default").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + assert_eq!((s.fill_text_size, s.fill_text_border, s.fill_text_border_color), (14.0, 1.5, red)); + let mut restored = PdfCraftApp::new(); + restored.restore(&h.state().persist()); + assert_eq!((restored.fill_text_size, restored.fill_text_border, restored.fill_text_border_color), (14.0, 1.5, red), "kept across launches"); + h.state_mut().views[0].comments.selected = None; + let (page, index) = type_at(&mut h, 350.0, "Next"); + let p = h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + assert_eq!((p.font_size, p.width, p.border_color), (Some(14.0), Some(1.5), red)); +} + #[test] fn fill_text_colour_survives_preview_commit_settings_and_save() { let mut h = harness(); @@ -1211,7 +1399,15 @@ fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() { app.open_bytes("form.pdf", None, FIXTURE.to_vec()).unwrap(); let id = app.views[0].id; app.session.apply(id, mark).unwrap(); - app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), size: 10.0, color: [0.0; 3], focus: false }); + app.views[0].fill_text = Some(TypeBox { + page: 0, + at: [40.0, 200.0], + text: "Typed".into(), + size: 10.0, + color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, + focus: false, + }); let kept = dir.join("kept.pdf"); app.save_override = Some(kept.to_string_lossy().into_owned()); assert!(app.save_active(SaveTarget::InPlace)); @@ -1222,7 +1418,15 @@ fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() { assert!(texts.iter().any(|t| t == "Typed"), "an open type box is saved: {texts:?}"); assert_eq!(reopened.session.get(reopened.views[0].id).unwrap().info.annotations.len(), 2, "the check mark stays too"); - app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 160.0], text: "More".into(), size: 10.0, color: [0.0; 3], focus: false }); + app.views[0].fill_text = Some(TypeBox { + page: 0, + at: [40.0, 160.0], + text: "More".into(), + size: 10.0, + color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, + focus: false, + }); app.flatten_fill_sign_on_save = true; let flat = dir.join("flat.pdf"); app.save_override = Some(flat.to_string_lossy().into_owned()); diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 12989e9e73..a7f5b4cf7f 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -889,6 +889,24 @@ fn a_saved_no_fill_clears_a_comment_tool_default_fill() { assert_eq!(c.comment_prefs.style(CommentTool::Rectangle).fill, None); } +/// A tool's saved "Same as text" border colour (`"border_color": null`) clears an own border +/// colour it had; a missing or malformed one keeps it. +#[test] +fn a_saved_same_as_text_clears_a_comment_tool_border_colour() { + use pdfcraft_ui_egui::comments::CommentTool; + let red = Some([0.89, 0.13, 0.13]); + let mut a = PdfCraftApp::new(); + a.comment_prefs.set_border_color(CommentTool::TextBox, red); + a.restore(r#"{"comment_styles":[{"tool":"comment.freetext","border_color":"red"}]}"#); + assert_eq!(a.comment_prefs.style(CommentTool::TextBox).border_color, red, "malformed keeps it"); + a.restore(r#"{"comment_styles":[{"tool":"comment.freetext","border_color":null}]}"#); + assert_eq!(a.comment_prefs.style(CommentTool::TextBox).border_color, None, "null clears it"); + let mut b = PdfCraftApp::new(); + b.comment_prefs.set_border_color(CommentTool::TextBox, red); + b.restore(&a.persist()); + assert_eq!(b.comment_prefs.style(CommentTool::TextBox).border_color, None, "a round trip keeps it cleared"); +} + /// Persisted per-tool comment styles go through their clamping setters: hostile or malformed /// values are clamped or ignored, never trusted (#340). #[test] diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index d383582915..9a06a0bc12 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -1046,7 +1046,15 @@ fn the_window_title_marks_a_draft_that_is_not_applied_yet() { h.run_steps(2); assert_eq!(h.state().window_title, "b.pdf — PdfCraft"); let i = h.state().active.unwrap(); - h.state_mut().views[i].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), size: 10.0, color: [0.0; 3], focus: false }); + h.state_mut().views[i].fill_text = Some(TypeBox { + page: 0, + at: [40.0, 200.0], + text: "Typed".into(), + size: 10.0, + color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, + focus: false, + }); h.run_steps(2); let s = h.state(); assert!(!s.session.get(s.views[i].id).unwrap().dirty, "nothing applied to the document yet"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e95e7252d9..cf6a3db0a9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3758,8 +3758,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/it/automation.rs::fill_sign_text_font_size_is_saved_and_validated"] -notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches. fill_sign_add accepts font_size for text and dates." +evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/it/automation.rs::fill_sign_text_font_size_is_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_border_frames_new_and_selected_text", "crates/annot/src/tests.rs::a_typewriter_border_goes_around_its_text_which_stays_put", "crates/automation/tests/it/automation.rs::fill_sign_text_border_is_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::the_toolbar_shows_and_changes_the_selected_texts_border", "crates/ui-egui/tests/it/fill_sign.rs::typed_text_made_default_is_what_new_text_gets", "crates/annot/src/tests.rs::a_text_border_can_have_its_own_colour"] +notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches (with a placed text selected it resizes that text too). A Border control (none to 3 pt) frames new text or the selected text, in the text's colour or a Border colour of its own; the box grows outwards so the text keeps its place. With a placed text selected, the border controls show its own border and border colour (also after Comment Properties changed them) and change only that text; without one they set new text's until PdfCraft quits. Make Current Properties Default on typed text (its menus, or the box in Comment Properties) sets what new text gets, its size and border, kept across launches. Text boxes and callouts take a border colour too (quick bar, Comment Properties), the stroking colour in /DA. fill_sign_add accepts font_size, border and border_color for text and dates; comment_edit border_color." [[feature]] id = "comment.text-box" @@ -4010,8 +4010,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = [] -evidence = ["crates/ui-egui/tests/it/comments.rs::make_current_properties_default", "crates/ui-egui/tests/it/comments.rs::properties_dialog_makes_the_strikethrough_colour_default"] -notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340)" +evidence = ["crates/ui-egui/tests/it/comments.rs::make_current_properties_default", "crates/ui-egui/tests/it/comments.rs::properties_dialog_makes_the_strikethrough_colour_default", "crates/ui-egui/tests/it/fill_sign.rs::typed_text_made_default_is_what_new_text_gets"] +notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340). Typed text, which no comment tool makes, gives Fill & Sign's new text its size and border" [[feature]] id = "comment.keep-tool-selected" From 496e37d72be8d5f041520a59312830ad8a98d029 Mon Sep 17 00:00:00 2001 From: ohagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:32:01 +0100 Subject: [PATCH 13/43] M5: text boxes and typed text take an oval or a circle for their border (#4543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fill & Sign: a Border control for typed text, and a border colour of its own Rebased onto main as one commit: main's Fill & Sign text colour (#2444) runs through the same places as the border (the open box, typed(), the page input, the toolbar and the preferences), so every step of the branch met it again. Text colour and border now travel together as TypedLook (typed_with), which keeps typed() and the new function under Clippy's argument limit; a border without its own colour takes the text's. Make Current Properties Default on typed text also keeps its colour for new text now. The branch's commits, in order: - Typewriter text: a border change grows its box on every side, so the text keeps its place - Fill & Sign: a Border control for typed text and dates - Parity: the typewriter border's evidence - i18n: the Border label in every catalog, recorded in ATTRIBUTION.toml - Text boxes, typewriter text and callouts: a border colour of their own - Fill & Sign: the toolbar follows the selected text; a Border colour button - fill_sign_add and comment_edit take `border_color` - Parity: the border colour and the toolbar following the selection - i18n: Border colour and Same as text in every catalog, recorded in ATTRIBUTION.toml - Typed text's border: /Border's width is read, a non-finite border colour refused, a saved Same as text restored - Fill & Sign: the toolbar changes only the selected text; Make Current Properties Default keeps new text's border - Review: Make Properties Default gives a text box's or callout's tool its border colour - i18n: the new labels in the Persian catalog, recorded in ATTRIBUTION.toml - Review: a test that dragging the Font size control resizes the selected text to where the drag ends - Rebase: the typed text's border in main's annot and comments modules (Style's border colour, exports), and craft_ui's ComboBox Co-Authored-By: Claude Opus 5.5 * M5: text boxes and typed text take an oval or a circle for their border Rebased onto #4146's new head as one commit (see there: main's Fill & Sign text colour runs through the same places). The border shape goes into TypedLook's border with the width and colour; a circle around typed text still takes the snug box. The branch's commits, in order: - Text boxes and typed text take an oval or a circle for their border - A circle goes snug around its text - Review: fill_sign_add takes an oval or a circle only with a border - Review: a circle made on a quarter-turned page is sized from the text as shown - Review: Border ▸ None takes an oval or a circle away with the line - Review: the Border colour button shows the selected text box's own colour - i18n: Border shape in the Persian catalog, recorded in ATTRIBUTION.toml - Review: Border ▸ None on a circled text is one undo step - Rebase: the border shape in main's annot, engine and comments modules; craft_ui's ComboBox; Fill & Sign's border controls in a function of their own (main's complexity limit) - Review: Thickness 0 in Comment Properties takes an oval or a circle away, as Border ▸ None does Co-Authored-By: Claude Opus 5.5 * Open the Font size popup first in the drag test: main moved the size into a popup * Border shape per selected comment, and split the oval/circle border out of draw_free_text (clippy cognitive complexity) * Re-run CI (the push of the main merge did not start it) --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps --- ROADMAP.md | 1 + crates/annot/src/appearance.rs | 100 ++++++-- crates/annot/src/create.rs | 7 +- crates/annot/src/info.rs | 5 +- crates/annot/src/lib.rs | 8 +- crates/annot/src/modify.rs | 181 +++++++++++++-- crates/annot/src/tests.rs | 214 ++++++++++++++++++ crates/annot/src/types.rs | 101 ++++++++- crates/automation/src/comments.rs | 41 +++- crates/automation/src/tools.rs | 3 + crates/automation/tests/it/automation.rs | 49 ++++ crates/engine/src/edit.rs | 16 +- crates/engine/src/edit/labels.rs | 1 + crates/engine/src/edit/run.rs | 2 + crates/engine/src/history.rs | 1 + crates/engine/src/lib.rs | 4 +- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/prefs.rs | 9 + crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/dialogs/comment_props.rs | 41 +++- crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/hotbar.rs | 70 ++++-- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tools/comments.rs | 9 +- crates/ui-egui/src/tools/comments/geometry.rs | 11 +- crates/ui-egui/src/tools/fill_sign.rs | 41 +++- crates/ui-egui/tests/it/comments.rs | 116 ++++++++++ crates/ui-egui/tests/it/fill_sign.rs | 132 ++++++++++- docs/ui-parity.md | 2 +- parity/acrobat-features.toml | 12 + 30 files changed, 1101 insertions(+), 86 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3ce7c13542..f104ee0d80 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -303,6 +303,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Estimates unchanged. - **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. - **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. +- **2026-10-24 (M5, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged. - **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged. - **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged. - **2026-10-24 (M3, arrow keys on a wide page):** → / ← scroll a page wider than the window (a plan zoomed in) sideways a line at a time and turn the page at its edge, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← turn it at once, and a page that fits across turns as before (#185). A synthetic viewer regression covers both. Estimates unchanged. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 9cdb548360..7148e9fc2e 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -366,10 +366,18 @@ fn stamp_turn(d: &Dict) -> i64 { /// The page `/Rotate` a text box's or Fill & Sign text's lines are turned back by /// (`/PCTextRotate`, written when it is added to a turned page). -fn text_turn(d: &Dict) -> i64 { +pub(crate) fn text_turn(d: &Dict) -> i64 { d.get(b"PCTextRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90 } +/// A FreeText's `/RD`: how far its text box lies inside `/Rect` from the left, bottom, right and +/// top. A distance a hair below 0 (rounding) reads as 0; `None` when one is negative or they can't +/// be read. +pub(crate) fn inset(d: &Dict) -> Option<[f64; 4]> { + let rd: [f64; 4] = nums(d, b"RD")?.try_into().ok()?; + rd.iter().all(|x| x.is_finite() && *x > -0.001).then(|| rd.map(|x| x.max(0.0))) +} + /// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that /// they can't, which would come out as `?`: what it is (for the message) and the character. The /// text of a text box, callout or Fill & Sign text, a line's caption, a measurement's label, a @@ -937,6 +945,48 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - Some(form(rect, c.as_bytes(), res)) } +/// The oval or circle (`shape`) around a text box whose whole box is `full`: its background +/// and border go into `c`; returns the text box inside it. +#[allow(clippy::too_many_arguments)] +fn shaped_border( + d: &Dict, + shape: crate::BorderShape, + full: [f64; 4], + turn: i64, + bw: f64, + bg: Option, + line_color: Rgb, + c: &mut String, +) -> Option<[f64; 4]> { + // Laid out as displayed: a quarter turn swaps the sides' differences. + let rect = match inset(d) { + Some(rd) => { + let (dx, dy) = if matches!(turn, 90 | 270) { (rd[1], rd[0]) } else { (rd[0], rd[1]) }; + [full[0] + dx, full[1] + dy, full[2] - dx, full[3] - dy] + } + None => shape.inside(full, bw, full), + }; + if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let h = bw / 2.0; + let [x0, y0, x1, y1] = [full[0] + h, full[1] + h, full[2] - h, full[3] - h]; + let path = match shape { + crate::BorderShape::Circle => { + let (cx, cy, r) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0).min(y1 - y0) / 2.0); + ellipse(cx - r, cy - r, cx + r, cy + r) + } + _ => ellipse(x0, y0, x1, y1), + }; + if let Some(bg) = bg { + c.push_str(&format!("{}{path}f\n", rg(bg))); + } + if bw > 0.0 { + c.push_str(&format!("{}{} w\n{}{path}S\n[] 0 d\n", rg_stroke(line_color), n(bw), dash(d))); + } + Some(rect) +} + fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { let d = context.dict; let parts = context.parts; @@ -995,26 +1045,36 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin return None; } } - if let Some(bg) = bg { - c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); - } - if bw > 0.0 { - let h = bw / 2.0; - c.push_str(&format!( - "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", - rg_stroke(line_color), - n(bw), - dash(d), - n(rect[0] + h), - n(rect[1] + h), - n(rect[2] - rect[0] - bw), - n(rect[3] - rect[1] - bw) - )); + // An oval or a circle (`/PCBorderShape`) around the text box, which is `/Rect` inset by + // `/RD` and keeps its place: background and border take the shape, the text is laid out + // in the text box as in a rectangle. + let shape = crate::BorderShape::of(d); + if shape != crate::BorderShape::Rectangle { + rect = shaped_border(d, shape, full, turn, bw, bg, line_color, &mut c)?; + } else { + if let Some(bg) = bg { + c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); + } + if bw > 0.0 { + let h = bw / 2.0; + c.push_str(&format!( + "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", + rg_stroke(line_color), + n(bw), + dash(d), + n(rect[0] + h), + n(rect[1] + h), + n(rect[2] - rect[0] - bw), + n(rect[3] - rect[1] - bw) + )); + } } let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); let pad = 2.0 + bw; let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); - let q = d.int(b"Q").unwrap_or(0); + // In an oval or a circle the lines are centred in the text box, up and down too. + let round = shape != crate::BorderShape::Rectangle; + let q = if round { 1 } else { d.int(b"Q").unwrap_or(0) }; // The text at its own opacity, where it has one. if parts.text != parts.fill { c.push_str("/GS1 gs\n"); @@ -1029,8 +1089,12 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin rg(text_color) )); let mut out = c.into_bytes(); + let lines = wrap(&text, size, width); let mut y = rect[3] - pad - size * 0.9; - for line in wrap(&text, size, width) { + if round { + y -= ((rect[3] - rect[1] - 2.0 * pad - lines.len() as f64 * size * 1.2) / 2.0).max(0.0); + } + for line in lines { if y < rect[1] - size { break; } diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index f12b2bba3b..6376655ce9 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,6 +1,6 @@ use super::draw::draw_appearance; use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; -use super::modify::{free_text_da, n, rich_text, set_part_keys}; +use super::modify::{free_text_da, n, rich_text, set_part_keys, shape_text_box}; use super::types::{ FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, }; @@ -392,12 +392,15 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } border(&mut d); rich_text(&mut d); - // The text is laid out for the page as displayed, like a stamp's label. if !matches!(new.shape, Shape::Callout { .. }) { + // The text is laid out for the page as displayed, like a stamp's label. let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); if matches!(turn, 90 | 180 | 270) { d.set(b"PCTextRotate".to_vec(), Object::Int(turn)); } + // An oval or a circle goes around the text box, its text wrapped as displayed: + // after `/PCTextRotate`, which swaps the box's sides on a quarter-turned page. + shape_text_box(&mut d, rect, style.border_shape, style.width); } } _ => {} diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index cf1b607664..ba181d7504 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -2,7 +2,7 @@ use super::create::finite; use super::draw::set_appearance; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs}; use super::modify::{fillable, touch, unlocked}; -use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; +use super::types::{BorderShape, FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; // ── reading ───────────────────────────────────────────────────────────────────────────────── @@ -219,6 +219,8 @@ pub struct Props { pub font_size: Option, /// Text boxes, typewriter text and callouts: the border's own colour (`None`: the text's). pub border_color: Option, + /// Text boxes and typewriter text: the border's shape (callouts have none to choose). + pub border_shape: Option, /// How opaque the fill is drawn, for a comment with a fill control (`opacity` is then its /// line's or border's). pub fill_opacity: Option, @@ -256,6 +258,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { fill: fill_of(d), font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(), + border_shape: (subtype == "FreeText" && !d.contains(b"CL")).then(|| BorderShape::of(d)), fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill), text_opacity: (subtype == "FreeText").then(|| appearance::part_opacity(d).text), subtype, diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 270be0115d..52c0aad74f 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -42,13 +42,13 @@ pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, - set_border_color, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, set_review_state, - set_style, with_replies_and_popups, + set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, + set_review_state, set_style, with_replies_and_popups, }; pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model}; pub use types::{ - AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, - StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, + AttachIcon, BorderShape, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, + Shape, StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, }; pub mod appearance; diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 8dbfd4c5b9..c7d87be090 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -5,8 +5,8 @@ use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; use super::info::{line_endings_of, text_value}; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots}; use super::types::{ - FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, - StyleChange, TextReplacement, + BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, + Shape, Style, StyleChange, TextReplacement, }; use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance}; @@ -159,10 +159,13 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int } /// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the -/// new text: the wrap width and top edge stay, the height fits the wrapped lines. +/// new text: the wrap width and top edge stay, the height fits the wrapped lines. A circle snug +/// around its text stays snug around the new text, centred where it was. pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; - let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText"); + let d = annot_dict(doc, r); + let free_text = d.name(b"Subtype") == Some(b"FreeText"); + let snug = free_text && is_snug(&d); doc.update_dict(r, |d| { d.set(b"Contents".to_vec(), PdfString::text(text)); // The rich-text version follows the new plain text (text boxes), or would contradict it. @@ -177,8 +180,16 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m // The box grows and shrinks with the text instead of clipping it. A locked box keeps its // text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even // where the standard fonts can't draw some of it (marked and reported, see - // `draw_appearance`). - match fitted_box(doc, r, text) { + // `draw_appearance`). A circle snug around its text stays snug around the new text; one + // made larger by hand keeps its size, only growing for more lines. + let fit = if snug { + snug_refit(doc, r) + } else { + let d = annot_dict(doc, r); + let circle = text_box_of(&d).filter(|_| BorderShape::of(&d) == BorderShape::Circle); + fitted_box(doc, r, text).map(|fit| circle.map_or(fit, |old| [fit[0], fit[1].min(old[1]), fit[2], fit[3]])) + }; + match fit { Some(rect) => apply_text_box(doc, r, rect, meta, true)?, None => draw_appearance(doc, r)?, } @@ -197,11 +208,12 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> { let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?; let rect = [rect[0], rect[1], rect[2], rect[3]]; // A callout re-fits its text box (`/Rect` inset by `/RD`); the leader line keeps its place. + // So does a text box inside an oval or a circle. let tb = if d.contains(b"CL") { let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| *x >= 0.0))?; [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]] } else { - rect + text_box_of(&d).unwrap_or(rect) }; let (x0, top) = (tb[0].min(tb[2]), tb[1].max(tb[3])); let w = tb[2] - tb[0]; @@ -388,9 +400,134 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") { return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized"))); } + // An oval's or a circle's frame is resized: its text box follows inside. + let shape = if subtype == "FreeText" { BorderShape::of(&d) } else { BorderShape::Rectangle }; + let rect = match (shape, text_box_of(&d)) { + (BorderShape::Rectangle, _) | (_, None) => rect, + // A circle's text box keeps its proportions and reaches the circle with its longer sides; + // the circle then goes around it again, never closer than its text allows. + (BorderShape::Circle, Some(was)) => { + let (o, w) = (normalize(rect), normalize(was)); + let (cx, cy, r) = ((o[0] + o[2]) / 2.0, (o[1] + o[3]) / 2.0, (o[2] - o[0]).min(o[3] - o[1]) / 2.0); + let (wx, wy) = ((w[2] - w[0]) / 2.0, (w[3] - w[1]) / 2.0); + let k = r / wx.max(wy).max(1e-6); + let (ix, iy) = ((wx * k).max(0.5), (wy * k).max(0.5)); + [cx - ix, cy - iy, cx + ix, cy + iy] + } + (shape, Some(was)) => shape.inside(rect, border_width_of(&d), was), + }; apply_text_box(doc, r, rect, meta, false) } +/// A FreeText's text box: `/Rect` inset by `/RD` for a callout or a text box inside an oval or a +/// circle, else `/Rect`. `None` when they can't be read. +pub(crate) fn text_box_of(d: &Dict) -> Option<[f64; 4]> { + let nums = |key: &[u8]| -> Option> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() }; + let rect = normalize(four(&nums(b"Rect")?)?); + if !d.contains(b"CL") && BorderShape::of(d) == BorderShape::Rectangle { + return Some(rect); + } + let rd = appearance::inset(d)?; + let tb = [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]]; + (tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0).then_some(tb) +} + +/// Give text box or typewriter text `d`, whose text box is `tb`, the border `shape` (`width` +/// wide): `/Rect` around it, `/RD` and `/PCBorderShape`, or for a rectangle `/Rect` the text +/// box itself again. +pub(crate) fn shape_text_box(d: &mut Dict, tb: [f64; 4], shape: BorderShape, width: f64) { + // A circle goes around the text as the box lays it out. + let text = (shape == BorderShape::Circle).then(|| text_extent(d, displayed_sides(d, tb).0 - 2.0 * (2.0 + width.max(0.0)))); + let outer = shape.around(tb, width, text); + d.set(b"Rect".to_vec(), num_array(&outer)); + if shape == BorderShape::Rectangle { + d.remove(b"PCBorderShape"); + d.remove(b"RD"); + } else { + d.set(b"PCBorderShape".to_vec(), Object::name(shape.name())); + // A circle touching the text box's sides would put them a rounding error outside it. + let tb = normalize(tb); + let rd = [tb[0] - outer[0], tb[1] - outer[1], outer[2] - tb[2], outer[3] - tb[3]].map(|x| x.max(0.0)); + d.set(b"RD".to_vec(), num_array(&rd)); + } +} + +/// The width of the widest line and the height of the lines of FreeText `d`'s text, as its +/// appearance lays the text out `wrap` wide. +fn text_extent(d: &Dict, wrap: f64) -> [f64; 2] { + let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); + let (_, size) = appearance::parse_da(d); + let lines = appearance::wrap(&text, size, wrap.max(1.0)); + let widest = lines.iter().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max); + [widest, lines.len().max(1) as f64 * size * 1.2] +} + +/// The width and height of FreeText `d`'s text box `tb` as displayed: swapped on a page turned by +/// a quarter, where its text is laid out upright. +fn displayed_sides(d: &Dict, tb: [f64; 4]) -> (f64, f64) { + let tb = normalize(tb); + let (w, h) = (tb[2] - tb[0], tb[3] - tb[1]); + if matches!(appearance::text_turn(d), 90 | 270) { (h, w) } else { (w, h) } +} + +/// FreeText `d`'s text box `tb`, with a border `width` wide, made as large as its text wrapped +/// `wrap` wide needs (its widest line and its lines, inside the padding a box sized to its text +/// has) and centred where it was: a circle goes around this, not around a box wider than its text. +fn snug_text_box(d: &Dict, tb: [f64; 4], width: f64, wrap: f64) -> [f64; 4] { + let tb = normalize(tb); + let pad = 2.0 + width.max(0.0); + let [w, h] = text_extent(d, wrap); + let (hx, hy) = (w / 2.0 + pad + 2.0, h / 2.0 + pad + 1.0); + let (hx, hy) = if matches!(appearance::text_turn(d), 90 | 270) { (hy, hx) } else { (hx, hy) }; + let (cx, cy) = ((tb[0] + tb[2]) / 2.0, (tb[1] + tb[3]) / 2.0); + [cx - hx, cy - hy, cx + hx, cy + hy] +} + +/// Whether FreeText `d` is a circle whose text box is snug around its text ([`snug_text_box`]): +/// it then follows its text as that changes, where one made larger by hand keeps its size. +fn is_snug(d: &Dict) -> bool { + let Some(tb) = text_box_of(d).filter(|_| BorderShape::of(d) == BorderShape::Circle) else { return false }; + let width = border_width_of(d); + let s = snug_text_box(d, tb, width, displayed_sides(d, tb).0 - 2.0 * (2.0 + width)); + let tb = normalize(tb); + ((s[2] - s[0]) - (tb[2] - tb[0])).abs() < 0.5 && ((s[3] - s[1]) - (tb[3] - tb[1])).abs() < 0.5 +} + +/// The text box of a circle snug around its text for the text and size it has now: its lines up to +/// 300 pt wide, centred where it was. +fn snug_refit(doc: &Document, r: ObjRef) -> Option<[f64; 4]> { + let d = annot_dict(doc, r); + let width = border_width_of(&d); + Some(snug_text_box(&d, text_box_of(&d)?, width, 300.0 - 2.0 * (2.0 + width))) +} + +/// Give a text box or typewriter text an oval, a circle or a rectangle for its border, and redraw +/// it. Its text box keeps its place; the oval or circle goes around it. For a circle the text box +/// first shrinks to its text, its lines wrapped as they are. +pub fn set_border_shape(doc: &mut Document, page: usize, index: usize, shape: BorderShape, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"FreeText") || d.contains(b"CL") { + return Err(AnnotError::Invalid("only text boxes and typewriter text have a border shape".into())); + } + let tb = text_box_of(&d).ok_or_else(|| AnnotError::Invalid("the text box can't be read".into()))?; + let tb = match shape { + BorderShape::Circle => { + let width = border_width_of(&d); + snug_text_box(&d, tb, width, displayed_sides(&d, tb).0 - 2.0 * (2.0 + width)) + } + _ => tb, + }; + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + shape_text_box(d, tb, shape, border_width_of(d)); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + /// At most this many points reshape a comment (a drawn polygon has a handful). const MAX_POINTS: usize = 100_000; @@ -503,6 +640,8 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } None => (rect, None), }; + let (shape, width) = (BorderShape::of(&d), border_width_of(&d)); + let free_text = d.name(b"Subtype") == Some(b"FreeText"); doc.update_dict(r, |d| { d.set(b"Rect".to_vec(), num_array(&outer)); match &cl { @@ -510,6 +649,8 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne d.set(b"CL".to_vec(), num_array(l)); d.set(b"RD".to_vec(), num_array(&[rect[0] - outer[0], rect[1] - outer[1], outer[2] - rect[2], outer[3] - rect[3]])); } + // An oval or a circle goes around the text box again. + None if free_text => shape_text_box(d, rect, shape, width), None => { d.remove(b"RD"); } @@ -586,17 +727,18 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha let free_text = subtype == "FreeText"; // Typewriter text has no box of its own: a border goes around its text, so the box grows // (or shrinks) by the change on every side and the text keeps its place and its lines. - let typewriter = free_text && d.name(b"IT") == Some(b"FreeTextTypeWriter") && !d.contains(b"CL"); + // So does a text box inside an oval or a circle, which goes around it again. + let shape = if free_text { BorderShape::of(&d) } else { BorderShape::Rectangle }; + let typewriter = free_text && (d.name(b"IT") == Some(b"FreeTextTypeWriter") || shape != BorderShape::Rectangle) && !d.contains(b"CL"); let regrow = width.filter(|_| typewriter).and_then(|w| { let by = w.max(0.0) - if d.contains(b"BS") || d.contains(b"Border") { border_width_of(&d) } else { 0.0 }; - let rect: Vec = d.get(b"Rect")?.as_array()?.iter().filter_map(|o| o.as_f64()).collect(); - let rect = grow(normalize(four(&rect)?), by); - (by != 0.0 && finite(&rect) && rect[2] - rect[0] >= 1.0 && rect[3] - rect[1] >= 1.0).then_some(rect) + let rect = grow(text_box_of(&d)?, by); + (by != 0.0 && finite(&rect) && rect[2] - rect[0] >= 1.0 && rect[3] - rect[1] >= 1.0).then_some((rect, w.max(0.0))) }); let before = d; doc.update_dict(r, |d| { - if let Some(rect) = regrow { - d.set(b"Rect".to_vec(), num_array(&rect)); + if let Some((rect, w)) = regrow { + shape_text_box(d, rect, shape, w); } if let Some(c) = color { if free_text { @@ -685,6 +827,7 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into())); } refuse_foreign_text(doc, &d)?; + let snug = is_snug(&d); let before = d; doc.update_dict(r, |d| { let (col, _) = appearance::parse_da(d); @@ -693,18 +836,18 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m touch(d, meta); })?; let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default(); - let result = match fitted_box(doc, r, &text) { - Some(fit) => { + let result = match (snug, if snug { snug_refit(doc, r) } else { fitted_box(doc, r, &text) }) { + // A circle snug around its text stays snug around it at the new size. + (true, Some(fit)) => apply_text_box(doc, r, fit, meta, false), + (false, Some(fit)) => { let cur = annot_dict(doc, r); - let nums: Vec = - cur.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); // Keep the top and sides; only the bottom edge moves, and only downwards. - match four(&nums).map(normalize) { + match text_box_of(&cur) { Some(old) if !cur.contains(b"CL") => apply_text_box(doc, r, [fit[0], fit[1].min(old[1]), fit[2], fit[3]], meta, false), _ => apply_text_box(doc, r, fit, meta, false), } } - None => redraw_or_restore(doc, r, before.clone()), + (_, None) => redraw_or_restore(doc, r, before.clone()), }; if result.is_err() { doc.set(r, Object::Dict(before)); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index f451ad0f8e..ff7f8925f0 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_incremental}; +use super::modify::text_box_of; use super::*; /// Two pages. Page 1 has an inline Square annotation in a direct /Annots array; page 2 has an @@ -356,6 +357,219 @@ fn restyle_and_resize_redraw_the_appearance() { assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_)))); } +/// Typed text or a text box takes an oval or a circle for its border: the text box keeps its +/// place and the shape goes around it (`/Rect` grows, `/RD` holds the text box, `/PCBorderShape` +/// the shape); back to a rectangle, it is the text box it was. A resized frame takes its text box +/// along, a thicker border leaves it where it is. +#[test] +fn text_boxes_take_an_oval_or_a_circle_around_their_text() { + let close = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01); + let nums = |d: &Dict, k: &[u8]| -> Vec { + d.get(k).and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default() + }; + // Where the text starts: its first line's text matrix. + let text_at = |doc: &Document, d: &Dict| ap_content(doc, d).lines().find(|l| l.ends_with(" Tm") || l.contains(" Tm ")).map(str::to_owned); + let mut doc = fixture(); + let mut tw = new(0, Shape::Typewriter { rect: [100.0, 100.0, 200.0, 120.0], font_size: 10.0 }); + tw.style.width = 1.0; + let t = add_annotation(&mut doc, &tw, &meta("t")).unwrap(); + // In an oval or a circle the text is centred in its text box (left-aligned in a box): one + // line of "hello" at 10 pt in the 100 × 20 text box centred on (150, 110), 3 pt from its top. + let centred = format!("1 0 0 1 {} {} Tm (hello) Tj", n(150.0 - appearance::text_width("hello", 10.0) / 2.0), n(107.0)); + assert!(text_at(&doc, &list(&doc, 0)[t]).is_some_and(|l| l.starts_with("1 0 0 1 103 ")), "a box: left-aligned"); + set_border_shape(&mut doc, 0, t, BorderShape::Oval, &meta("")).unwrap(); + let doc2 = reopen(&doc); + let d = &list(&doc2, 0)[t]; + // Around the 100 × 20 text box, centred: √2 its size, two points more for the 1 pt border. + let (ax, ay) = (50.0 * std::f64::consts::SQRT_2 + 2.0, 10.0 * std::f64::consts::SQRT_2 + 2.0); + assert!(close(&rect(d), &[150.0 - ax, 110.0 - ay, 150.0 + ax, 110.0 + ay]), "{:?}", rect(d)); + assert!(close(&nums(d, b"RD"), &[ax - 50.0, ay - 10.0, ax - 50.0, ay - 10.0]), "{:?}", nums(d, b"RD")); + assert_eq!(d.name(b"PCBorderShape"), Some(&b"Oval"[..])); + let ap = ap_content(&doc2, d); + assert!(ap.contains(" c\n") && ap.contains("S\n") && !ap.contains("re S"), "an oval, not a box: {ap}"); + assert_eq!(text_at(&doc2, d).as_deref(), Some(centred.as_str()), "the text keeps its place"); + assert_eq!(props(&doc2, 0, t).unwrap().border_shape, Some(BorderShape::Oval)); + // A circle around the text: the text box shrinks to "hello" with its padding, centred where it + // was, and the circle touches its sides (the text a point clear of the border inside). + set_border_shape(&mut doc, 0, t, BorderShape::Circle, &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + let hw = appearance::text_width("hello", 10.0) / 2.0; + let r = (hw + 5.0).max(hw.hypot(6.0) + 2.0); + assert!(close(&rect(d), &[150.0 - r, 110.0 - r, 150.0 + r, 110.0 + r]), "{:?}", rect(d)); + assert!(close(&nums(d, b"RD"), &[r - hw - 5.0, r - 10.0, r - hw - 5.0, r - 10.0]), "{:?}", nums(d, b"RD")); + assert_eq!(text_at(&doc, d).as_deref(), Some(centred.as_str())); + // A thicker border: the typed text's box grows by the change on every side, so its text keeps + // its place inside the thicker line (as with a rectangle), and the circle goes around that. + set_style(&mut doc, 0, t, StyleChange::new(None, None, Some(3.0), None), &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + assert_eq!(text_at(&doc, d).as_deref(), Some(centred.as_str())); + let r3 = (hw + 7.0).max(hw.hypot(6.0) + 4.0); + assert!(close(&rect(d), &[150.0 - r3, 110.0 - r3, 150.0 + r3, 110.0 + r3]), "{:?}", rect(d)); + // Its frame resized: the text box follows inside, keeping its proportions, its longer sides + // reaching the circle. + set_rect(&mut doc, 0, t, [150.0 - 2.0 * r3, 110.0 - 2.0 * r3, 150.0 + 2.0 * r3, 110.0 + 2.0 * r3], &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + let rd = nums(d, b"RD"); + let tb = [rect(d)[0] + rd[0], rect(d)[1] + rd[1], rect(d)[2] - rd[2], rect(d)[3] - rd[3]]; + assert!(((tb[2] - tb[0]) / (tb[3] - tb[1]) - (hw + 7.0) / 12.0).abs() < 0.01, "the text box keeps its proportions: {tb:?}"); + assert!(close(&rect(d), &[150.0 - 2.0 * r3, 110.0 - 2.0 * r3, 150.0 + 2.0 * r3, 110.0 + 2.0 * r3]), "{:?}", rect(d)); + // A rectangle again: the text box it has, nothing around it. + set_border_shape(&mut doc, 0, t, BorderShape::Rectangle, &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + assert!(close(&rect(d), &tb), "{:?} vs {tb:?}", rect(d)); + assert!(!d.contains(b"RD") && !d.contains(b"PCBorderShape")); + assert!(ap_content(&doc, d).contains("re S")); + + // A filled text box made with an oval: the background takes the shape too. + let mut tb = new(0, Shape::TextBox { rect: [300.0, 500.0, 400.0, 540.0], font_size: 12.0 }); + (tb.style.fill, tb.style.width, tb.style.border_shape) = (Some([1.0, 1.0, 0.6]), 1.0, BorderShape::Oval); + let b = add_annotation(&mut doc, &tb, &meta("b")).unwrap(); + let d = &list(&doc, 0)[b]; + assert_eq!(d.name(b"PCBorderShape"), Some(&b"Oval"[..])); + let ap = ap_content(&doc, d); + assert!(ap.contains("1 1 0.6 rg") && ap.contains(" c\nh\nf\n") && !ap.contains(" re f"), "{ap}"); + // A callout's border stays a rectangle. + let c = add_annotation( + &mut doc, + &new( + 0, + Shape::Callout { + rect: [300.0, 300.0, 400.0, 340.0], + knee: [280.0, 320.0], + point: [250.0, 280.0], + ending: LineEnding::OpenArrow, + font_size: 10.0, + }, + ), + &meta("c"), + ) + .unwrap(); + assert!(matches!(set_border_shape(&mut doc, 0, c, BorderShape::Oval, &meta("")), Err(AnnotError::Invalid(_)))); + assert_eq!(props(&doc, 0, c).unwrap().border_shape, None); + assert_eq!(BorderShape::parse("CIRCLE"), Some(BorderShape::Circle)); +} + +/// A circle goes snug around short text, as a number on a drawing is circled: the text box is no +/// wider than its text needs and the circle touches its sides, where one through the box's corners +/// stood well clear. It follows the text and its size as they change; one made larger by hand +/// keeps its size. +#[test] +fn a_circle_goes_snug_around_its_text_and_follows_it() { + let close = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01; + let mut doc = fixture(); + // "3" at 10 pt with a 1 pt border, in the box Fill & Sign sizes to it: the digit with 2 pt of + // padding and 4 pt to spare across, a line and 6 pt down, the border around that. + let w3 = appearance::text_width("3", 10.0); + let mut typed = new(0, Shape::Typewriter { rect: [100.0, 680.0, 110.0 + w3, 700.0], font_size: 10.0 }); + (typed.contents, typed.style.width, typed.style.border_shape) = ("3".into(), 1.0, BorderShape::Circle); + let t = add_annotation(&mut doc, &typed, &meta("t")).unwrap(); + let frame = |doc: &Document| { + let r = rect(&list(doc, 0)[t]); + ((r[2] - r[0], r[3] - r[1]), ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0)) + }; + let centre = (105.0 + w3 / 2.0, 690.0); + // As tall as the box, 20 pt across: through the corners it was 29. + assert!(close(frame(&doc).0, (20.0, 20.0)) && close(frame(&doc).1, centre), "{:?}", frame(&doc)); + // The text box Fill & Sign gives a box to type into, 40 pt wide: a circle goes snug around its + // text there too. + let mut wide = typed.clone(); + wide.shape = Shape::Typewriter { rect: [300.0, 680.0, 342.0, 700.0], font_size: 10.0 }; + wide.style.border_shape = BorderShape::Rectangle; + let b = add_annotation(&mut doc, &wide, &meta("b")).unwrap(); + set_border_shape(&mut doc, 0, b, BorderShape::Circle, &meta("")).unwrap(); + let r = rect(&list(&doc, 0)[b]); + assert!(close((r[2] - r[0], r[3] - r[1]), (20.0, 20.0)), "{r:?}"); + // More text: the circle grows with it, about the same centre; less, and it shrinks back. + set_contents(&mut doc, 0, t, "123", &meta("")).unwrap(); + let w = appearance::text_width("123", 10.0) / 2.0; + let d = 2.0 * (w + 5.0).max(10.0).max(w.hypot(6.0) + 2.0); + assert!(close(frame(&doc).0, (d, d)) && close(frame(&doc).1, centre), "{:?}", frame(&doc)); + set_contents(&mut doc, 0, t, "3", &meta("")).unwrap(); + assert!(close(frame(&doc).0, (20.0, 20.0)) && close(frame(&doc).1, centre), "{:?}", frame(&doc)); + // A larger font: a larger circle, still snug. + set_font_size(&mut doc, 0, t, 20.0, &meta("")).unwrap(); + let w = appearance::text_width("3", 20.0) / 2.0; + let d = 2.0 * (w + 5.0).max(16.0).max(w.hypot(12.0) + 2.0); + assert!(close(frame(&doc).0, (d, d)) && close(frame(&doc).1, centre), "{:?}", frame(&doc)); + set_font_size(&mut doc, 0, t, 10.0, &meta("")).unwrap(); + assert!(close(frame(&doc).0, (20.0, 20.0)), "{:?}", frame(&doc)); + // Made larger by hand, it keeps its size as its text changes. + set_rect(&mut doc, 0, t, [centre.0 - 20.0, centre.1 - 20.0, centre.0 + 20.0, centre.1 + 20.0], &meta("")).unwrap(); + assert!(close(frame(&doc).0, (40.0, 40.0)), "{:?}", frame(&doc)); + set_contents(&mut doc, 0, t, "4", &meta("")).unwrap(); + assert!(close(frame(&doc).0, (40.0, 40.0)) && close(frame(&doc).1, centre), "{:?}", frame(&doc)); +} + +/// A circle around a line wider than it is tall touches its text box's sides: `/RD` is 0 there, +/// not a rounding error below it, and a hair below 0 still reads. (A text box that couldn't be +/// read stopped following its text, and its text wrapped in a narrower one.) The circle keeps its +/// centre as its text changes. +#[test] +fn a_circle_touching_its_text_box_keeps_following_its_text() { + let mut doc = fixture(); + // Sized to its text: 2 pt of padding and half a point to spare across, a line down. + let (w, h) = (appearance::text_width("Testtext", 12.0) + 4.5, 12.0 * 1.11 + 4.0); + let mut b = new(0, Shape::TextBox { rect: [210.79, 700.0 - h, 210.79 + w, 700.0], font_size: 12.0 }); + (b.contents, b.style.width) = ("Testtext".into(), 0.0); + let t = add_annotation(&mut doc, &b, &meta("t")).unwrap(); + // Circle, then a 1 pt border, which the circle grows by. + set_border_shape(&mut doc, 0, t, BorderShape::Circle, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(None, None, Some(1.0), None), &meta("")).unwrap(); + let lines = |doc: &Document| ap_content(doc, &list(doc, 0)[t]).matches(" Tj").count(); + let frame = |doc: &Document| { + let r = rect(&list(doc, 0)[t]); + ((r[2] - r[0], r[3] - r[1]), ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0)) + }; + let rd = |doc: &Document| -> Vec { list(doc, 0)[t].get(b"RD").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect() }; + assert!(rd(&doc).iter().all(|x| *x >= 0.0) && rd(&doc)[0] < 0.01, "touching the sides: {:?}", rd(&doc)); + assert_eq!(lines(&doc), 1, "Testtext on one line"); + let (wide, centre) = frame(&doc); + set_contents(&mut doc, 0, t, "KRS", &meta("")).unwrap(); + let (small, at) = frame(&doc); + assert!(small.0 < wide.0 - 10.0 && (small.0 - small.1).abs() < 0.01, "a smaller circle: {small:?} from {wide:?}"); + assert!((at.0 - centre.0).abs() < 0.01 && (at.1 - centre.1).abs() < 0.01, "about its centre: {at:?} {centre:?}"); + assert_eq!(lines(&doc), 1); + // A hair below 0, as rounding left it: still read, so the circle follows its text again. + let (_, r) = annot_ref(&mut doc, 0, t).unwrap(); + let mut was = rd(&doc); + was[0] = -2.8e-14; + doc.update_dict(r, |d| d.set(b"RD".to_vec(), num_array(&was))).unwrap(); + assert!(text_box_of(&list(&doc, 0)[t]).is_some()); + set_contents(&mut doc, 0, t, "Testtext", &meta("")).unwrap(); + assert!((frame(&doc).0.0 - wide.0).abs() < 0.01 && lines(&doc) == 1, "{:?}", frame(&doc)); + assert!(rd(&doc).iter().all(|x| *x >= 0.0), "{:?}", rd(&doc)); +} + +/// On a page turned a quarter, typed text is laid out as the page is shown (`/PCTextRotate`), and a +/// circle made with it goes around the text as shown: as large as around the same text on an +/// upright page. (Made before the turn was set, its text wrapped at the box's displayed height and +/// the circle went around several lines.) +#[test] +fn a_circle_made_on_a_turned_page_is_sized_as_on_an_upright_one() { + let w = appearance::text_width("12345", 10.0); + let typed = |rect: [f64; 4]| { + let mut t = new(0, Shape::Typewriter { rect, font_size: 10.0 }); + (t.contents, t.style.width, t.style.border_shape) = ("12345".into(), 1.0, BorderShape::Circle); + t + }; + let size = |doc: &Document, i: usize| { + let r = rect(&list(doc, 0)[i]); + (r[2] - r[0], r[3] - r[1]) + }; + let mut upright = fixture(); + let u = add_annotation(&mut upright, &typed([100.0, 680.0, 110.0 + w, 700.0]), &meta("u")).unwrap(); + for turn in [90, 270] { + let mut doc = fixture(); + let page = page_refs(&doc).unwrap()[0]; + doc.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Int(turn))).unwrap(); + // The same box as shown: its sides swapped in user space. + let t = add_annotation(&mut doc, &typed([100.0, 680.0, 120.0, 690.0 + w]), &meta("t")).unwrap(); + assert_eq!(list(&doc, 0)[t].get(b"PCTextRotate").and_then(Object::as_int), Some(turn)); + let (turned, straight) = (size(&doc, t), size(&upright, u)); + assert!((turned.0 - straight.0).abs() < 0.01 && (turned.1 - straight.1).abs() < 0.01, "{turn}°: {turned:?}, upright {straight:?}"); + } +} + /// A rectangle's fill, and a text box's border, background and text, each drawn at an opacity of /// its own: the line's in `/CA`, the fill's in `/ca` (PDF 2.0), the text's in `/PCTextOpacity`, and /// in the appearance `/GS0` (strokes at the line's, fills at the fill's) and `/GS1` for the text. diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index abc5c17601..f6bcc68a7e 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -1,5 +1,5 @@ use super::create::normalize; -use super::{ObjRef, appearance}; +use super::{Dict, ObjRef, appearance}; pub type Rgb = [f64; 3]; @@ -591,6 +591,8 @@ pub struct Style { /// A text box's, typewriter text's or callout's border colour where it differs from the /// text's (`None`: the text colour), the stroking colour in its `/DA`. pub border_color: Option, + /// A text box's or typewriter text's border shape (a callout's is always a rectangle). + pub border_shape: BorderShape, /// The fill's own opacity, 0–1 (`/ca`), where it differs from `opacity` (which is then the /// line's or border's); `None`: as `opacity`. pub fill_opacity: Option, @@ -627,7 +629,102 @@ pub struct TextReplacement<'a> { impl Default for Style { fn default() -> Self { - Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None, fill_opacity: None, text_opacity: None, border_color: None } + Self { + color: [1.0, 0.82, 0.0], + opacity: 1.0, + width: 1.0, + fill: None, + fill_opacity: None, + text_opacity: None, + border_color: None, + border_shape: BorderShape::Rectangle, + } + } +} + +/// The shape of a text box's or typewriter text's border (PdfCraft's `/PCBorderShape`, absent for +/// a rectangle). An oval or a circle goes around the text box, which keeps its place: `/Rect` +/// grows to hold it, centred on the text box, and `/RD` says where the text box is inside +/// (§12.5.6.6), as for a callout. Other apps that redraw the comment draw a rectangle. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BorderShape { + #[default] + Rectangle, + Oval, + Circle, +} + +impl BorderShape { + pub const ALL: [BorderShape; 3] = [BorderShape::Rectangle, BorderShape::Oval, BorderShape::Circle]; + + pub fn name(self) -> &'static str { + match self { + BorderShape::Rectangle => "Rectangle", + BorderShape::Oval => "Oval", + BorderShape::Circle => "Circle", + } + } + + /// `rectangle`, `oval` or `circle`, in any case. + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|b| b.name().eq_ignore_ascii_case(s)) + } + + /// The border shape of FreeText `d`: always a rectangle for a callout. + pub fn of(d: &Dict) -> Self { + if d.contains(b"CL") { + return BorderShape::Rectangle; + } + match d.name(b"PCBorderShape") { + Some(b"Oval") => BorderShape::Oval, + Some(b"Circle") => BorderShape::Circle, + _ => BorderShape::Rectangle, + } + } + + /// The rectangle this shape, with a border `width` wide, takes around text box `tb`, centred on + /// it: an oval through the text box's corners (`√2` its size), a point clear of the border; a + /// circle around the text's lines (`text`: the widest line's width and the lines' height, when + /// known), a point clear of the border and touching the text box's longer sides at least + /// (its corners, padding only, may lie outside), else through the box's corners; the text box + /// itself for a rectangle. + pub fn around(self, tb: [f64; 4], width: f64, text: Option<[f64; 2]>) -> [f64; 4] { + let tb = normalize(tb); + let (cx, cy, hx, hy) = ((tb[0] + tb[2]) / 2.0, (tb[1] + tb[3]) / 2.0, (tb[2] - tb[0]) / 2.0, (tb[3] - tb[1]) / 2.0); + let gap = width.max(0.0) + 1.0; + let (ax, ay) = match self { + BorderShape::Rectangle => return tb, + BorderShape::Oval => (hx * std::f64::consts::SQRT_2 + gap, hy * std::f64::consts::SQRT_2 + gap), + BorderShape::Circle => { + let r = match text { + Some([w, h]) => ((w / 2.0).hypot(h / 2.0) + gap).max(hx).max(hy), + None => hx.hypot(hy) + gap, + }; + (r, r) + } + }; + [cx - ax, cy - ay, cx + ax, cy + ay] + } + + /// The text box inside `outer` for this shape when none is recorded: the inverse of + /// [`Self::around`] for an oval, a box through a circle's corners for a circle, keeping the + /// proportions of `was` (the one it had). + pub(crate) fn inside(self, outer: [f64; 4], width: f64, was: [f64; 4]) -> [f64; 4] { + let o = normalize(outer); + let (cx, cy, hx, hy) = ((o[0] + o[2]) / 2.0, (o[1] + o[3]) / 2.0, (o[2] - o[0]) / 2.0, (o[3] - o[1]) / 2.0); + let gap = width.max(0.0) + 1.0; + let (ix, iy) = match self { + BorderShape::Rectangle => return o, + BorderShape::Oval => ((hx - gap) / std::f64::consts::SQRT_2, (hy - gap) / std::f64::consts::SQRT_2), + BorderShape::Circle => { + let w = normalize(was); + let (wx, wy) = ((w[2] - w[0]) / 2.0, (w[3] - w[1]) / 2.0); + let k = (hx.min(hy) - gap) / wx.hypot(wy).max(1e-6); + (wx * k, wy * k) + } + }; + let (ix, iy) = (ix.max(0.5), iy.max(0.5)); + [cx - ix, cy - iy, cx + ix, cy + iy] } } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index b8b815ac37..78f4cefbe0 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -5,7 +5,9 @@ use std::collections::HashMap; -use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort}; +use pdfcraft_engine::{ + BorderShape, Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort, +}; use pdfcraft_render::{Annotation, PageInfo}; use serde_json::{Value, json}; @@ -190,6 +192,12 @@ impl Automation { "rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))), "color": c.color.map(hex), }); + // A text box's or typed text's oval or circle (a rectangle is not reported). + if c.subtype == "FreeText" + && let Some(shape) = doc.comment_props(c.page, c.index).and_then(|p| p.border_shape).filter(|s| *s != BorderShape::Rectangle) + { + v["border_shape"] = json!(shape.name().to_ascii_lowercase()); + } // How opaque it is drawn: as a whole, or its fill and text each at their own. // Not for replies, which are drawn with the comment they answer. if !c.is_reply() @@ -466,6 +474,12 @@ impl Automation { if let Some(w) = a.opt_num("width")? { style.width = w.max(0.0); } + if let Some(b) = border_shape(a)? { + if !matches!(shape, Shape::TextBox { .. } | Shape::Typewriter { .. }) { + return Err(ToolError::InvalidArgs("border_shape is for a text box or typewriter text".into())); + } + style.border_shape = b; + } let contents = a.opt_str("contents")?.unwrap_or_default().to_string(); let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(); // Replace Text: the struck-out text and a grouped caret holding `contents`. @@ -526,6 +540,7 @@ impl Automation { } pub(crate) fn fill_sign_add(&mut self, a: &Args) -> Result { + let border_shape = border_shape(a)?; use pdfcraft_engine::FillMark; let page = self.page(a)?; let info = self.doc(a)?.info.pages[page].clone(); @@ -565,6 +580,13 @@ impl Automation { if border_color.is_some() && border <= 0.0 { return Err(ToolError::InvalidArgs("border_color needs a border".into())); } + if border_shape.is_some() && !matches!(kind, "text" | "date") { + return Err(ToolError::InvalidArgs("border_shape is only for type text or date".into())); + } + // Without a border an oval or a circle would grow the box around nothing drawn. + if border_shape.is_some_and(|s| s != BorderShape::Rectangle) && border <= 0.0 { + return Err(ToolError::InvalidArgs("border_shape needs a border".into())); + } if let Some(path) = a.opt_str("path")? { if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() { return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into())); @@ -588,9 +610,11 @@ impl Automation { let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?; return self.apply(a, edit); } + // A circle goes around the text, so its box is no wider than the text needs. + let min_width = if border_shape == Some(BorderShape::Circle) { 0.0 } else { 20.0 }; let text_at = |t: &str| { let longest = t.lines().map(|line| pdfcraft_engine::annot_text::text_width(line, size)).fold(0.0, f64::max); - let w = (longest + 8.0).clamp(20.0, 600.0); + let w = (longest + 8.0).clamp(min_width, 600.0); let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1); let h = rows as f64 * size * 1.2 + 6.0; let [x0, y0, x1, y1] = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); @@ -644,6 +668,7 @@ impl Automation { if matches!(shape, Shape::Typewriter { .. }) { style.width = border; style.border_color = border_color; + style.border_shape = border_shape.unwrap_or_default(); } self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author })) } @@ -715,6 +740,9 @@ impl Automation { let color = if c.eq_ignore_ascii_case("text") { None } else { Some(parse_color(c)?) }; edits.push(Edit::BorderColorAnnotation { page, index, color }); } + if let Some(shape) = border_shape(a)? { + edits.push(Edit::BorderShapeAnnotation { page, index, shape }); + } // After `opacity`, which sets the whole comment's. let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?); if fill_opacity.into_iter().chain(text_opacity).any(|o| !(0.0..=1.0).contains(&o)) { @@ -756,7 +784,7 @@ impl Automation { } if edits.is_empty() { return Err(ToolError::InvalidArgs( - "nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, border_color, font_size, rect, move or points".into(), + "nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, border_color, border_shape, font_size, rect, move or points".into(), )); } let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } }; @@ -854,6 +882,13 @@ impl Automation { } } +/// `border_shape`: `rectangle`, `oval` or `circle`. +fn border_shape(a: &Args) -> Result> { + a.opt_str("border_shape")? + .map(|s| BorderShape::parse(s).ok_or_else(|| ToolError::InvalidArgs(format!("unknown border_shape {s:?}: rectangle, oval or circle")))) + .transpose() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index d15c1d915f..bf2abea185 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -862,6 +862,7 @@ pub fn tools() -> Vec { "fill_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The fill's own opacity: rectangle, oval, polygon, cloud, text box, typewriter text or callout." }, "text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity: text box, typewriter text or callout." }, "width": { "type": "number", "minimum": 0, "description": "Line width in points." }, + "border_shape": { "type": "string", "enum": ["rectangle", "oval", "circle"], "description": "A text box's or typewriter text's border shape: an oval or a circle goes around the text box." }, "endings": { "type": "array", "items": { "type": "string", "enum": ["None", "Square", "Circle", "Diamond", "OpenArrow", "ClosedArrow", "Butt", "ROpenArrow", "RClosedArrow", "Slash"] }, "minItems": 1, "maxItems": 2, "description": "Line endings. Two names (start, end) for a line, arrow or polyline; one name for a callout." }, "ortho": { "type": "boolean", "description": "line/arrow/polygon/cloud/polyline: hold each end or corner level or plumb with the one before, whichever is nearer (Shift while drawing)." }, "font_size": { "type": "number", "exclusiveMinimum": 0 }, @@ -1004,6 +1005,7 @@ pub fn tools() -> Vec { "width": { "type": "number", "minimum": 0 }, "fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." }, "border_color": { "type": "string", "description": "A text box's, typewriter text's or callout's border colour: #RRGGBB, a colour name, or \"text\" for the text's colour." }, + "border_shape": { "type": "string", "enum": ["rectangle", "oval", "circle"], "description": "A text box's or typewriter text's border shape: an oval or a circle goes around the text, which keeps its place." }, "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "move": point(), @@ -1422,6 +1424,7 @@ pub fn tools() -> Vec { "color": color(), "border": { "type": "number", "minimum": 0, "maximum": 12, "description": "For type text or date: a border around it in points, in the text's colour (default 0, none)." }, "border_color": { "type": "string", "description": "With border: its own colour, #RRGGBB or a colour name." }, + "border_shape": { "type": "string", "enum": ["rectangle", "oval", "circle"], "description": "With border, for type text or date: the border's shape (default rectangle); an oval or a circle goes around the text." }, "path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." }, "scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." }, "sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index a4ee239a62..6f493b824d 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2135,6 +2135,55 @@ fn numbering_pages_through_tools() { assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 1, "to": 1, "style": "klingon" })), Err(ToolError::InvalidArgs(_)))); } +/// Typed text and text boxes take an oval or a circle for their border through the tools; +/// comment_list reports it (a rectangle is not reported). Only typed text, dates and text boxes +/// have one, and only the three shapes. +#[test] +fn text_borders_take_an_oval_or_a_circle_through_the_tools() { + let dir = workdir("border-shape"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok( + &mut a, + "fill_sign_add", + json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": "12", "border": 1, "border_shape": "circle" }), + ); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "width": 1, "border_shape": "Oval" }), + ); + let list = |a: &mut Automation| ok(a, "comment_list", json!({ "doc": doc, "page": 1 }))["comments"].as_array().unwrap().clone(); + let shapes: Vec = list(&mut a).iter().map(|c| c["border_shape"].clone()).collect(); + assert_eq!(shapes, [json!("circle"), json!("oval")]); + let typed = list(&mut a)[0].clone(); + // Snug around "12": the box no wider than the text needs, the circle as wide as that box. + let r: Vec = typed["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + let w = pdfcraft_engine::annot_text::text_width("12", 10.0) + 10.0; + assert!((r[2] - r[0] - w).abs() < 0.05 && (r[3] - r[1] - w).abs() < 0.05, "{r:?}"); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": typed["page"], "index": typed["index"], "border_shape": "oval" })); + assert_eq!(list(&mut a)[0]["border_shape"], "oval"); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": typed["page"], "index": typed["index"], "border_shape": "rectangle" })); + assert!(list(&mut a)[0].get("border_shape").is_none(), "a rectangle is not reported"); + for (tool, args) in [ + ("fill_sign_add", json!({ "doc": doc, "page": 1, "type": "check", "at": [60, 60], "border_shape": "oval" })), + ("fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "text": "7", "at": [60, 60], "border_shape": "circle" })), + ("comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [80, 10, 120, 40], "border_shape": "oval" })), + ("comment_edit", json!({ "doc": doc, "page": typed["page"], "index": typed["index"], "border_shape": "star" })), + ] { + assert!(matches!(a.call(tool, &args), Err(ToolError::InvalidArgs(_))), "{args}"); + } + let callout = ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "callout", "rect": [110, 100, 190, 140], "to": [40, 60], "contents": "Look" }), + ); + assert!(matches!( + a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": callout["comment"]["index"], "border_shape": "oval" })), + Err(ToolError::Failed(_)) + )); +} + /// A rectangle's fill and a text box's text each take an opacity of their own through the tools /// (the line or border keeps the comment's `opacity`); `opacity` sets the whole comment's again. #[test] diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index ab3393a0f5..1bcf24108f 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -3,10 +3,11 @@ use std::sync::Arc; use crate::{ - AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label, - LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState, - Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture, - image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, + InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, + Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, + field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, + xfa, xfa_after_data_change, }; mod labels; @@ -285,6 +286,13 @@ pub enum Edit { index: usize, color: Option, }, + /// Give a text box or typewriter text an oval, a circle or a rectangle for its border: the + /// text box keeps its place, the oval or circle goes around it. + BorderShapeAnnotation { + page: usize, + index: usize, + shape: BorderShape, + }, /// Comment properties ▸ General / note icon. SetAnnotationInfo { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index a244d70c93..615ac0fea5 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -237,6 +237,7 @@ impl Edit { | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } | Edit::BorderColorAnnotation { .. } + | Edit::BorderShapeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"), Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 3d24a22fa2..588a3a847f 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -68,6 +68,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } | Edit::BorderColorAnnotation { .. } + | Edit::BorderShapeAnnotation { .. } | Edit::SetAnnotationInfo { .. } | Edit::SetMeasurementScale { .. } => { if p.annotate() { @@ -589,6 +590,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::PartOpacity { page, index, fill, text } => pdfcraft_annot::set_part_opacity(doc, *page, *index, *fill, *text, &cx.meta())?, Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, Edit::BorderColorAnnotation { page, index, color } => pdfcraft_annot::set_border_color(doc, *page, *index, *color, &cx.meta())?, + Edit::BorderShapeAnnotation { page, index, shape } => pdfcraft_annot::set_border_shape(doc, *page, *index, *shape, &cx.meta())?, Edit::SetAnnotationInfo { page, index, author, subject, icon } => { pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; } diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index c679406154..ce51df5e61 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -72,6 +72,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } | Edit::BorderColorAnnotation { .. } + | Edit::BorderShapeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Scope::Comments, Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ca25500ec0..1c113d0d82 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -113,8 +113,8 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr pub use pdfcraft_annot::appearance as annot_text; pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; pub use pdfcraft_annot::{ - Activation3d, AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, - Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, + Activation3d, AppearanceCache, AttachIcon, BorderShape, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, + OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, }; pub use pdfcraft_annot::{MAX_MODEL_BYTES as MAX_3D_MODEL_BYTES, ModelFormat as Model3dFormat}; pub use pdfcraft_forms::detect; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 7c7710b5b8..61b85cd7f1 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -112,6 +112,7 @@ impl PdfCraftApp { fill_text_color: [0.0; 3], fill_text_border: 0.0, fill_text_border_color: None, + fill_text_border_shape: pdfcraft_engine::BorderShape::Rectangle, fill_text_default: fill_sign::TextBorder::NONE, dialog: None, updates: updates::Updates::default(), diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index ee33caeb5c..89789d8a4c 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -105,6 +105,7 @@ impl PdfCraftApp { "fill_text_size": self.fill_text_size, "fill_text_border": self.fill_text_default.width, "fill_text_border_color": self.fill_text_default.color, + "fill_text_border_shape": self.fill_text_default.shape.name(), "date_format": self.session.date_format(), // Null follows the interface language. "date_language": self.session.date_language(), @@ -123,6 +124,7 @@ impl PdfCraftApp { "width": s.width, "fill": s.fill, "border_color": s.border_color, + "border_shape": s.border_shape.name(), })) .collect::>(), // Drawn signatures keep their original form (older settings read the same). @@ -316,6 +318,9 @@ impl PdfCraftApp { if let Some(width) = v["fill_text_border"].as_f64().and_then(fill_sign::valid_text_border) { self.fill_text_default.width = width; } + if let Some(shape) = v["fill_text_border_shape"].as_str().and_then(pdfcraft_engine::BorderShape::parse) { + self.fill_text_default.shape = shape; + } // `null` is a saved "the text's colour"; a missing or malformed one keeps the setting. match v.get("fill_text_border_color") { Some(serde_json::Value::Null) => self.fill_text_default.color = None, @@ -329,6 +334,7 @@ impl PdfCraftApp { // Fill & Sign text starts from the saved border. self.fill_text_border = self.fill_text_default.width; self.fill_text_border_color = self.fill_text_default.color; + self.fill_text_border_shape = self.fill_text_default.shape; if let Some(prefs) = RedactPrefs::from_json(&v["redact_prefs"]) { self.redact_prefs = prefs.clone(); self.redact_defaults = prefs; @@ -405,6 +411,9 @@ impl PdfCraftApp { } None => {} } + if let Some(shape) = e["border_shape"].as_str().and_then(pdfcraft_engine::BorderShape::parse) { + self.comment_prefs.set_border_shape(tool, shape); + } } } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9681126048..9004d88205 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3161,7 +3161,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { y_shift, fill_text_size: app.fill_text_size, fill_text_color: app.fill_text_color, - fill_text_border: crate::fill_sign::TextBorder { width: app.fill_text_border, color: app.fill_text_border_color }, + fill_text_border: crate::fill_sign::TextBorder { + width: app.fill_text_border, + color: app.fill_text_border_color, + shape: app.fill_text_border_shape, + }, today, can_paste_image, }; diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index e3762ce0c0..12752dde3c 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -113,8 +113,9 @@ impl PdfCraftApp { self.fill_text_color = color; } let width = p.width.and_then(crate::fill_sign::valid_text_border).unwrap_or(0.0); - self.fill_text_default = crate::fill_sign::TextBorder { width, color: p.border_color }; - (self.fill_text_border, self.fill_text_border_color) = (width, p.border_color); + let border = crate::fill_sign::TextBorder { width, color: p.border_color, shape: drawn_border_shape(p).unwrap_or_default() }; + self.fill_text_default = border; + (self.fill_text_border, self.fill_text_border_color, self.fill_text_border_shape) = (border.width, border.color, border.shape); self.notify_fmt("New {tool} comments will look like this one", &[("tool", &kind)]); return; } @@ -129,6 +130,9 @@ impl PdfCraftApp { self.comment_prefs.set_style(tool, style); // A text box's or callout's own border colour (cleared for tools that have none). self.comment_prefs.set_border_color(tool, p.border_color); + if let Some(shape) = drawn_border_shape(p) { + self.comment_prefs.set_border_shape(tool, shape); + } // The fill's and text's own opacities where they show (else the opacity is the whole // comment's). With neither a border nor a fill the text is the whole comment: its drawn // opacity, which Comment Properties shows, is the whole comment's default. @@ -184,6 +188,12 @@ impl PdfCraftApp { } } +/// The border shape `p` draws: a Thickness of 0 takes an oval or a circle away with the line, as +/// the toolbar's Border ▸ None does. +fn drawn_border_shape(p: &CommentProps) -> Option { + p.border_shape.map(|s| if p.width.is_some_and(|w| w <= 0.0) { pdfcraft_engine::BorderShape::Rectangle } else { s }) +} + /// Which of the comment's parts show an opacity of their own: (its fill, its text). A text box's /// text, border and fill each do once it has a border or a fill; a filled shape's line and fill /// do; anything else has one opacity for the whole comment. @@ -270,6 +280,19 @@ fn changes(o: &CommentProps, e: &CommentProps, page: usize, index: usize, t: &Co if t.restylable && t.font_size.is_some() && e.border_color != o.border_color { out.push(Edit::BorderColorAnnotation { page, index, color: e.border_color }); } + // After the width (StyleAnnotation), whose border the shape goes around: each selected + // text keeps its own shape unless the draft's changed, and loses it with its border. + let mut shaped = after; + if e.border_shape != o.border_shape { + shaped.border_shape = e.border_shape; + } + if t.restylable + && t.border_shape.is_some() + && (e.border_shape != o.border_shape || width.is_some()) + && let Some(shape) = drawn_border_shape(&shaped).filter(|s| Some(*s) != t.border_shape) + { + out.push(Edit::BorderShapeAnnotation { page, index, shape }); + } let author = (e.author != o.author).then(|| e.author.clone()); let subject = (e.subject != o.subject).then(|| e.subject.clone()); let icon = (e.icon != o.icon).then_some(e.icon).flatten().filter(|_| t.icon.is_some()); @@ -351,6 +374,20 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo }); ui.end_row(); } + // Text boxes and typewriter text: an oval or a circle goes around the text. + if let Some(shape) = e.border_shape.as_mut() { + let label = ui.label(tl!("Border shape")); + craft_ui::ComboBox::from_id_salt("border-shape") + .selected_text(tl!(shape.name())) + .show_ui(ui, |ui| { + for s in pdfcraft_engine::BorderShape::ALL { + ui.selectable_value(shape, s, tl!(s.name())); + } + }) + .response + .labelled_by(label.id); + ui.end_row(); + } if e.fillable { ui.label(tl!("Fill colour")); if let Some(f) = crate::comments::fill_picker(ui, e.fill) { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index e5e2d49b0d..4439dfe493 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -931,6 +931,7 @@ fn comment_page(edit: &Edit) -> Option { | Edit::PartOpacity { page, .. } | Edit::FontSizeAnnotation { page, .. } | Edit::BorderColorAnnotation { page, .. } + | Edit::BorderShapeAnnotation { page, .. } | Edit::SetAnnotationInfo { page, .. } => Some(*page), _ => None, } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index fafae309ff..8080758598 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -323,8 +323,8 @@ fn selected_fill_text(app: &PdfCraftApp) -> Option<(usize, usize)> { .then_some((page, annotation)) } -/// Fill & Sign's Border controls for typed text: its width and colour, for the selected text -/// (`picked`), or for new text when none is selected. The label shows in a vertical bar, as the +/// Fill & Sign's Border controls for typed text: its width, colour and shape, for the selected +/// text (`picked`), or for new text when none is selected. The label shows in a vertical bar, as the /// font size's does. fn fill_text_border_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, picked: Option<(usize, usize)>, vertical: bool) { // The selected text's own border (Comment Properties may have changed it @@ -333,9 +333,13 @@ fn fill_text_border_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, picked: O // Default on typed text keeps it. let selected = picked.and_then(|(page, annotation)| { let p = app.active_ids().and_then(|(_, id)| app.session.get(id))?.comment_props(page, annotation)?; - Some((page, annotation, crate::fill_sign::TextBorder { width: p.width.unwrap_or(0.0), color: p.border_color })) + let own = crate::fill_sign::TextBorder { width: p.width.unwrap_or(0.0), color: p.border_color, shape: p.border_shape.unwrap_or_default() }; + Some((page, annotation, own)) }); - let was = selected.map_or(crate::fill_sign::TextBorder { width: app.fill_text_border, color: app.fill_text_border_color }, |(_, _, own)| own); + let was = selected.map_or( + crate::fill_sign::TextBorder { width: app.fill_text_border, color: app.fill_text_border_color, shape: app.fill_text_border_shape }, + |(_, _, own)| own, + ); let mut border = was; // A border around the text, in its colour; the text keeps its place. let border_label = |w: f64| if w > 0.0 { format!("{w} pt") } else { tl!("None").to_string() }; @@ -363,18 +367,48 @@ fn fill_text_border_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, picked: O { border.color = color; } + // Its shape: an oval or a circle goes around the text, which keeps its place. + if was.width > 0.0 { + craft_ui::ComboBox::from_id_salt("fill-text-border-shape") + .selected_text(tl!(was.shape.name())) + .width(96.0) + .show_ui(ui, |ui| { + for shape in pdfcraft_engine::BorderShape::ALL { + ui.selectable_value(&mut border.shape, shape, tl!(shape.name())); + } + }) + .response + .on_hover_text(tl!("Border shape")); + } + // No border, no shape: an oval or a circle left without its line would + // keep its box grown around a shape that isn't drawn. + if border.width <= 0.0 { + border.shape = pdfcraft_engine::BorderShape::Rectangle; + } match selected { Some((page, index, _)) => { + // One undo step for what one choice changed (Border ▸ None takes a + // circle's line and its shape). + let mut edits = Vec::new(); if border.width != was.width { - app.apply_edit(Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(border.width), endings: None }); + edits.push(Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(border.width), endings: None }); } if border.color != was.color { - app.apply_edit(Edit::BorderColorAnnotation { page, index, color: border.color }); + edits.push(Edit::BorderColorAnnotation { page, index, color: border.color }); + } + if border.shape != was.shape { + edits.push(Edit::BorderShapeAnnotation { page, index, shape: border.shape }); + } + if edits.len() > 1 { + app.apply_edit(Edit::Batch { label: "Change comment properties".into(), edits }); + } else if let Some(edit) = edits.pop() { + app.apply_edit(edit); } } None => { app.fill_text_border = border.width; app.fill_text_border_color = border.color; + app.fill_text_border_shape = border.shape; } } } @@ -427,18 +461,24 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve if let QuickTool::Comment(tool) = app.quick_tool { controls_divider(ui, t, vertical); // Text boxes and callouts: the border's own colour, for new ones and the selected one - // this tool made (not a locked one). + // this tool made (not a locked one). With one selected the button shows its colours + // (Comment Properties may have changed them), else the tool's. if tool.has_border_color() { let style = app.comment_prefs.style(tool); - if let Some(color) = comments::border_colour_button(ui, style.border_color, style.color) { + let selected = app.active_ids().and_then(|(index, id)| { + let (page, annotation) = app.views.get(index)?.comments.selected?; + let doc = app.session.get(id)?; + let made = doc.allows_annotation() + && doc.info.annotations.iter().any(|a| a.page == page && a.index == annotation && !a.locked && comments::made_by(a, tool)); + made.then(|| (page, annotation, doc.comment_props(page, annotation))) + }); + let (shown, text) = match selected.as_ref().and_then(|(.., p)| p.as_ref()) { + Some(p) => (p.border_color, p.color.unwrap_or(style.color)), + None => (style.border_color, style.color), + }; + if let Some(color) = comments::border_colour_button(ui, shown, text) { app.comment_prefs.set_border_color(tool, color); - if let Some((index, id)) = app.active_ids() - && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) - && app.session.get(id).is_some_and(|doc| { - doc.allows_annotation() - && doc.info.annotations.iter().any(|a| a.page == page && a.index == annotation && !a.locked && comments::made_by(a, tool)) - }) - { + if let Some((page, annotation, _)) = selected { app.apply_edit(Edit::BorderColorAnnotation { page, index: annotation, color }); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0cb7237376..94e83f451a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -342,6 +342,8 @@ pub struct PdfCraftApp { pub fill_text_border: f64, /// That border's own colour (`None`: the text's). pub fill_text_border_color: Option, + /// That border's shape (an oval or a circle goes around the text). + pub fill_text_border_shape: pdfcraft_engine::BorderShape, /// The border new Fill & Sign text starts each launch with, kept across launches: what Make /// Current Properties Default last took from typed text (none at first). pub fill_text_default: fill_sign::TextBorder, diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 451e5e4000..a906386d73 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -34,7 +34,7 @@ use geometry::handle_pos; #[cfg(test)] pub(crate) use geometry::{caret_snap, snapped_caret}; pub(crate) use geometry::{corner_ratio, page_rotation, resized}; -pub use geometry::{drawn_text_box_rect, text_box_rect}; +pub use geometry::{drawn_text_box_rect, snug_text_box_rect, text_box_rect}; #[cfg(test)] use input::hover_tip; pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input}; @@ -359,6 +359,13 @@ impl CommentPrefs { } } + /// The text box tool's border shape (the other tools keep a rectangle). + pub fn set_border_shape(&mut self, tool: CommentTool, shape: pdfcraft_engine::BorderShape) { + if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { + s.border_shape = if tool == CommentTool::TextBox { shape } else { pdfcraft_engine::BorderShape::Rectangle }; + } + } + /// The tool's fill's own opacity (0–100 %) and a text box's or callout's text's (10–100 %), /// for new comments; `None`: as the tool's opacity. pub fn set_part_opacity(&mut self, tool: CommentTool, fill: Option, text: Option) { diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index 4f3b338fe6..7e291be4db 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -159,10 +159,19 @@ pub(crate) fn caret_snap(text: &PageText, vx: f32, vy: f32) -> Option<([f32; 2], /// The box for `text` at `size` with its displayed top-left corner at `at`, on a page turned by /// `rotation` (its `/Rotate`): on a quarter-turned page it is taller than wide in user space. pub fn text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64) -> [f64; 4] { + sized_text_box_rect(at, text, size, rotation, 40.0) +} + +/// [`text_box_rect`] without its 40 pt minimum width: the box of text a circle goes around. +pub fn snug_text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64) -> [f64; 4] { + sized_text_box_rect(at, text, size, rotation, 0.0) +} + +fn sized_text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64, min_width: f64) -> [f64; 4] { use pdfcraft_engine::annot_text::{text_width, wrap}; let pad = 2.0; let longest = text.lines().map(|l| text_width(l, size)).fold(0.0, f64::max); - let w = (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0); + let w = (longest + 2.0 * pad + 4.0).clamp(min_width, 300.0); let lines = wrap(text, size, w - 2.0 * pad).len().max(1); let h = lines as f64 * size * 1.2 + 2.0 * pad + 2.0; pdfcraft_engine::text_box_at(rotation, at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]) diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 7e98eda05b..c7e270fdb9 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -6,7 +6,7 @@ use crate::file_filter::AddFilterAnyCase as _; use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2}; use pdfcraft_engine::signature_scan::{DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, SCAN_MAX_SIDE, ScanError}; -use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Rgb, Shape, SignatureImage, SignaturePhoto, Style, Turn}; +use pdfcraft_engine::{BorderShape, Edit, FillMark, NewAnnotation, PhotoRect, Rgb, Shape, SignatureImage, SignaturePhoto, Style, Turn}; use pdfcraft_render::{DocInfo, PageInfo}; use crate::canvas::{DocView, PageToolContext, PageXform}; @@ -220,16 +220,17 @@ pub struct TypeBox { pub focus: bool, } -/// The border around typed text: its width in points (0: none) and its own colour (`None`: the -/// text's). +/// The border around typed text: its width in points (0: none), its own colour (`None`: the +/// text's) and its shape (an oval or a circle goes around the text). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct TextBorder { pub width: f64, pub color: Option, + pub shape: BorderShape, } impl TextBorder { - pub const NONE: Self = Self { width: 0.0, color: None }; + pub const NONE: Self = Self { width: 0.0, color: None, shape: BorderShape::Rectangle }; } /// The default size Fill & Sign uses for typed text. @@ -281,12 +282,18 @@ pub struct TypedLook { /// border goes around the box sized to the text, so the text sits where it would without one. pub fn typed_with(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str, look: TypedLook) -> Edit { let width = valid_text_border(look.border.width).unwrap_or(0.0); - let [x0, y0, x1, y1] = crate::comments::text_box_rect(at, text, size, rotation); + // A circle goes around the text, so its box is no wider than the text needs. + let [x0, y0, x1, y1] = if width > 0.0 && look.border.shape == BorderShape::Circle { + crate::comments::snug_text_box_rect(at, text, size, rotation) + } else { + crate::comments::text_box_rect(at, text, size, rotation) + }; let shape = Shape::Typewriter { rect: [x0 - width, y0 - width, x1 + width, y1 + width], font_size: size }; let style = Style { color: valid_text_color(look.color).unwrap_or([0.0; 3]), width, border_color: look.border.color.filter(|_| width > 0.0), + border_shape: if width > 0.0 { look.border.shape } else { BorderShape::Rectangle }, ..Style::default_for(&shape) }; Edit::AddAnnotation(NewAnnotation { page, shape, style, contents: text.to_string(), author: author.to_string() }) @@ -642,7 +649,29 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, if t.border.width > 0.0 { let w = (t.border.width as f32 * zoom).max(1.0); let color = t.border.color.map_or(Color32::BLACK, crate::comments::color32); - ui.painter().rect_stroke(r.rect, 0.0, Stroke::new(w, color), egui::StrokeKind::Outside); + match t.border.shape { + BorderShape::Rectangle => { + ui.painter().rect_stroke(r.rect, 0.0, Stroke::new(w, color), egui::StrokeKind::Outside); + } + // A circle around the text typed so far, as `typed` will draw it: the box sized to + // the text, the border around that. + BorderShape::Circle => { + let (bw, size) = (t.border.width, t.size); + let longest = pdfcraft_engine::annot_text::text_width(t.text.trim(), size); + let (tw, th) = (longest + 8.0 + 2.0 * bw, size * 1.2 + 6.0 + 2.0 * bw); + let o = BorderShape::Circle.around([0.0, 0.0, tw, th], bw, Some([longest, size * 1.2])); + let centre = pos + egui::vec2((tw / 2.0 - bw) as f32, (th / 2.0 - bw) as f32) * zoom; + let radius = (o[2] - o[0]) as f32 / 2.0 * zoom - w / 2.0; + ui.painter().add(egui::Shape::circle_stroke(centre, radius, Stroke::new(w, color))); + } + // An oval around it, as it will be drawn (half the line outside). + shape => { + let b = r.rect.expand(w); + let o = shape.around([b.min.x, b.min.y, b.max.x, b.max.y].map(f64::from), f64::from(w), None); + let half = egui::vec2((o[2] - o[0]) as f32 / 2.0 - w / 2.0, (o[3] - o[1]) as f32 / 2.0 - w / 2.0); + ui.painter().add(egui::Shape::ellipse_stroke(r.rect.center(), half, Stroke::new(w, color))); + } + } } if t.focus { r.request_focus(); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 0396714c18..2308ba9e0f 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1373,6 +1373,80 @@ fn shapes_take_a_fill_colour_from_the_quick_bar_and_properties() { assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill, Some([0.0, 0.0, 1.0])); } +/// Comment Properties ▸ Border shape puts an oval or a circle around a text box's text; a +/// callout has none to choose. Make Properties Default carries it into the Text box tool. +#[test] +fn text_box_properties_choose_the_border_shape() { + use pdfcraft_engine::BorderShape; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 }; + let mut style = pdfcraft_engine::Style::default_for(&shape); + style.width = 1.0; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: "Pos. 3".into(), + author: "Tester".into(), + }))); + h.run_steps(2); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + h.get_by_label("Border shape"); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + assert_eq!(d.original.border_shape, Some(BorderShape::Rectangle)); + d.edited.border_shape = Some(BorderShape::Oval); + d.make_default = true; + } + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.comment_props(0, 0).unwrap().border_shape, Some(BorderShape::Oval)); + assert_eq!(doc.can_undo(), Some("Change comment properties")); + assert_eq!(s.comment_prefs.style(CommentTool::TextBox).border_shape, BorderShape::Oval, "the default for new text boxes"); +} + +/// Thickness 0 in Comment Properties takes a circle away with the line, as Border ▸ None does on +/// the toolbar, and a default made from it draws no circle either. +#[test] +fn no_thickness_in_text_box_properties_takes_the_circle_away() { + use pdfcraft_engine::BorderShape; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 }; + let mut style = pdfcraft_engine::Style::default_for(&shape); + style.width = 1.0; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: "Pos. 3".into(), + author: "Tester".into(), + }))); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::BorderShapeAnnotation { page: 0, index: 0, shape: BorderShape::Circle })); + h.state_mut().comment_prefs.set_border_shape(CommentTool::TextBox, BorderShape::Circle); + h.run_steps(2); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + assert_eq!(d.original.border_shape, Some(BorderShape::Circle)); + d.edited.width = Some(0.0); + d.make_default = true; + } + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let p = doc.comment_props(0, 0).unwrap(); + assert_eq!((p.width, p.border_shape), (Some(0.0), Some(BorderShape::Rectangle))); + assert_eq!(doc.can_undo(), Some("Change comment properties")); + assert_eq!(s.comment_prefs.style(CommentTool::TextBox).border_shape, BorderShape::Rectangle, "the default for new text boxes"); +} + /// A filled rectangle's line and fill, and a text box's text, border and fill, each take an /// opacity of their own in Properties; Make Properties Default carries them into the tool, so /// the next rectangle is drawn with a see-through fill and an opaque line. @@ -2412,6 +2486,47 @@ fn a_text_box_made_default_gives_the_tool_its_border_colour() { assert_eq!(h.state().comment_prefs.style(CommentTool::TextBox).border_color, red); } +/// The Text box tool's Border colour button shows the selected text box's own border colour +/// (Comment Properties may have given it one), and the tool's again with none selected. +#[test] +fn the_border_colour_button_shows_the_selected_text_boxs_colour() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + let mut h = harness(|app| app.set_option("quick", "freetext").unwrap()); + let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 }; + let style = Style { width: 1.0, ..Style::default_for(&shape) }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "Framed".into(), + author: "Tester".into() + }))); + h.run_steps(2); + let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Framed")).map(|a| a.index).expect("the text box"); + // A border colour of its own, which the tool doesn't have. + assert!(h.state_mut().apply_edit(Edit::BorderColorAnnotation { page: 0, index, color: Some([0.0, 0.47, 0.84]) })); + h.run_steps(2); + // The colour the button's square is drawn in. + let shown = |h: &Harness<'static, PdfCraftApp>| { + let button = h.get_by_label("Border colour").rect(); + h.output().shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Rect(r) if r.stroke.width == 3.0 && button.contains_rect(r.rect) => Some(r.stroke.color), + _ => None, + }) + }; + // A new text box comes selected. + h.state_mut().views[0].comments.selected = None; + h.run_steps(3); + let tool = shown(&h).expect("the button's square"); + h.state_mut().views[0].comments.selected = Some((0, index)); + h.run_steps(3); + let own = shown(&h).expect("the button's square"); + assert!(own != tool && own.b() > 200 && own.r() < 30, "the selected text box's blue: {own:?}, the tool's {tool:?}"); + h.state_mut().views[0].comments.selected = None; + h.run_steps(3); + assert_eq!(shown(&h), Some(tool), "the tool's colour again"); +} + /// #1302: making an arrow's properties the default updates the Arrow tool, not later plain lines. #[test] fn an_arrows_default_does_not_restyle_lines() { @@ -3161,6 +3276,7 @@ fn a_moved_comment_is_drawn_where_it_goes_before_release() { fill_opacity: None, text_opacity: None, border_color: None, + border_shape: pdfcraft_engine::BorderShape::Rectangle, }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page: 0, diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 55b6bdafd9..b5bb52cfa7 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -426,6 +426,125 @@ fn the_toolbar_shows_and_changes_the_selected_texts_border() { assert!(shows(&h, "None")); } +/// With a border, typed text can have an oval or a circle around it instead of a box (a +/// position number in a circle on a plan): the toolbar's shape control sets it for new text and +/// for the selected text, and shows the selected text's own. The text stays where it was typed. +#[test] +fn typed_text_takes_an_oval_or_a_circle_from_the_toolbar() { + use pdfcraft_engine::BorderShape; + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + h.run_steps(2); + assert_eq!(h.query_all_by_label("Border shape").count(), 0, "no shape without a border"); + h.state_mut().fill_text_border = 1.0; + h.run_steps(2); + // The shape control shows "Rectangle"; pick Circle for new text. It is the last of the text's + // controls, so in a vertical bar it may sit below the bar's end: scrolled to first, as by hand. + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.value().as_deref() == Some("Rectangle")) + .expect("the shape control") + .scroll_to_me(); + h.run_steps(2); + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Rectangle")).expect("the shape control").click(); + h.run_steps(2); + h.get_by_label("Circle").click(); + h.run_steps(2); + assert_eq!(h.state().fill_text_border_shape, BorderShape::Circle); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("12".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (page, index) = { + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("12")).cloned().unwrap(); + (a.page, a.index) + }; + let props = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + assert_eq!(props(&h).border_shape, Some(BorderShape::Circle), "new text gets the circle"); + let r = h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().find(|a| a.index == index).unwrap().rect; + assert!(((r[2] - r[0]) - (r[3] - r[1])).abs() < 0.01, "a circle's square box: {r:?}"); + // Snug around "12" at 10 pt: its box no wider than the text needs (not the 40 pt a box to type + // into keeps), so the circle is as wide as that box, where it was 50 pt around the wider one. + let w = pdfcraft_engine::annot_text::text_width("12", 10.0) + 10.0; + let across = f64::from(r[2] - r[0]); + assert!((across - w.max(20.0)).abs() < 0.01, "the circle is {across} pt across, not {w}: {r:?}"); + // Selected, an oval from the toolbar: one undo step. + h.state_mut().views[0].comments.selected = Some((page, index)); + h.run_steps(3); + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.value().as_deref() == Some("Circle")) + .expect("shows the text's own") + .scroll_to_me(); + h.run_steps(2); + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Circle")).expect("shows the text's own").click(); + h.run_steps(2); + h.get_by_label("Oval").click(); + h.run_steps(4); + assert_eq!(props(&h).border_shape, Some(BorderShape::Oval)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Change comment properties")); + assert_eq!(h.state().fill_text_border_shape, BorderShape::Circle, "new text keeps the circle"); + // As Comment Properties does: the toolbar shows it. + h.state_mut().apply_edit(pdfcraft_engine::Edit::BorderShapeAnnotation { page, index, shape: BorderShape::Rectangle }); + h.run_steps(3); + assert!(h.query_all_by_role(egui::accesskit::Role::ComboBox).any(|n| n.value().as_deref() == Some("Rectangle")), "the selected text's own"); + + // The circle for new text lasts until PdfCraft quits; a saved default is read back. + let mut restored = PdfCraftApp::new(); + restored.restore(&h.state().persist()); + assert_eq!(restored.fill_text_border_shape, BorderShape::Rectangle); + restored.restore(r#"{"fill_text_border_shape":"oval"}"#); + assert_eq!(restored.fill_text_border_shape, BorderShape::Oval); + restored.restore(r#"{"fill_text_border_shape":"star"}"#); + assert_eq!(restored.fill_text_border_shape, BorderShape::Oval, "an unknown shape keeps the setting"); +} + +/// Border ▸ None takes an oval or a circle away with its line, from the selected text and for new +/// text: without one, the text's box stayed grown around a shape that isn't drawn. +#[test] +fn no_border_takes_the_oval_or_circle_away_too() { + use pdfcraft_engine::BorderShape; + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + h.state_mut().fill_text_border = 1.0; + h.state_mut().fill_text_border_shape = BorderShape::Circle; + h.run_steps(2); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("12".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (page, index) = { + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("12")).cloned().unwrap(); + (a.page, a.index) + }; + let props = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + assert_eq!(props(&h).border_shape, Some(BorderShape::Circle)); + let no_border = |h: &mut Harness<'static, PdfCraftApp>| { + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("1 pt")).expect("the Border control").click(); + h.run_steps(2); + h.get_by_label("None").click(); + h.run_steps(4); + }; + // The selected text: no line, and a rectangle again, its box the text's. + h.state_mut().views[0].comments.selected = Some((page, index)); + h.run_steps(3); + no_border(&mut h); + assert_eq!(props(&h).width, Some(0.0)); + assert_eq!(props(&h).border_shape.unwrap_or_default(), BorderShape::Rectangle); + // One undo step brings back the line and the circle together. + h.state_mut().undo(); + h.run_steps(2); + assert_eq!((props(&h).width, props(&h).border_shape), (Some(1.0), Some(BorderShape::Circle)), "one undo step"); + h.state_mut().redo(); + h.run_steps(2); + assert_eq!(h.state().fill_text_border_shape, BorderShape::Circle, "new text keeps its circle"); + // New text: none, and a rectangle when a border is chosen again. + h.state_mut().views[0].comments.selected = None; + h.run_steps(3); + no_border(&mut h); + assert_eq!((h.state().fill_text_border, h.state().fill_text_border_shape), (0.0, BorderShape::Rectangle)); +} + /// The Border control puts a frame around new Fill & Sign text, and around the text that is /// selected; the box grows outwards, so the text stays where it was typed. The choice persists. #[test] @@ -487,7 +606,7 @@ fn fill_text_border_frames_new_and_selected_text() { /// border, and keeps it across launches. #[test] fn typed_text_made_default_is_what_new_text_gets() { - use pdfcraft_engine::Edit; + use pdfcraft_engine::{BorderShape, Edit}; let mut h = harness(); h.state_mut().execute("sign.fill.text"); let type_at = |h: &mut Harness<'static, PdfCraftApp>, y: f32, text: &str| { @@ -504,6 +623,7 @@ fn typed_text_made_default_is_what_new_text_gets() { for edit in [ Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(1.5), endings: None }, Edit::BorderColorAnnotation { page, index, color: red }, + Edit::BorderShapeAnnotation { page, index, shape: BorderShape::Circle }, Edit::FontSizeAnnotation { page, index, size: 14.0 }, ] { assert!(h.state_mut().apply_edit(edit)); @@ -515,14 +635,18 @@ fn typed_text_made_default_is_what_new_text_gets() { h.get_by_label("OK").click(); h.run_steps(3); let s = h.state(); - assert_eq!((s.fill_text_size, s.fill_text_border, s.fill_text_border_color), (14.0, 1.5, red)); + assert_eq!((s.fill_text_size, s.fill_text_border, s.fill_text_border_color, s.fill_text_border_shape), (14.0, 1.5, red, BorderShape::Circle)); let mut restored = PdfCraftApp::new(); restored.restore(&h.state().persist()); - assert_eq!((restored.fill_text_size, restored.fill_text_border, restored.fill_text_border_color), (14.0, 1.5, red), "kept across launches"); + assert_eq!( + (restored.fill_text_size, restored.fill_text_border, restored.fill_text_border_color, restored.fill_text_border_shape), + (14.0, 1.5, red, BorderShape::Circle), + "kept across launches" + ); h.state_mut().views[0].comments.selected = None; let (page, index) = type_at(&mut h, 350.0, "Next"); let p = h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); - assert_eq!((p.font_size, p.width, p.border_color), (Some(14.0), Some(1.5), red)); + assert_eq!((p.font_size, p.width, p.border_color, p.border_shape), (Some(14.0), Some(1.5), red, Some(BorderShape::Circle))); } #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b6c764af48..3523db9eed 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -76,7 +76,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Print dialog with preview | yes | yes | shipped | | Document Properties (description, security, fonts, initial view, custom, advanced) | yes | all but Custom and XMP | partial | | Field Properties (General, Appearance, Position, Options, Actions, Format, Validate, Calculate, Signed) | yes | all but Signed | partial | -| Comment properties and properties bar | yes | yes; a text box's text, border and fill and a shape's line and fill each take an opacity of their own (Acrobat: one per comment) | shipped | +| Comment properties and properties bar | yes | yes; a text box's text, border and fill and a shape's line and fill each take an opacity of their own (Acrobat: one per comment); text boxes and typed text take an oval or a circle for their border (Acrobat: a rectangle) | shipped | | Select several comments; copy and paste comments between documents | yes | yes: ⌘/Ctrl-click adds, Delete, a drag, Properties and Colour act on all, ⌘C/⌘V as XFDF (also across PdfCraft processes) | shipped | | Preferences | ≈ 20 pages | Interface language, Documents and view, Identity, Fill & Sign, JavaScript | partial (gap 19) | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cf6a3db0a9..9635898230 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3761,6 +3761,18 @@ tools = ["fill_sign_add"] evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/it/automation.rs::fill_sign_text_font_size_is_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_border_frames_new_and_selected_text", "crates/annot/src/tests.rs::a_typewriter_border_goes_around_its_text_which_stays_put", "crates/automation/tests/it/automation.rs::fill_sign_text_border_is_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::the_toolbar_shows_and_changes_the_selected_texts_border", "crates/ui-egui/tests/it/fill_sign.rs::typed_text_made_default_is_what_new_text_gets", "crates/annot/src/tests.rs::a_text_border_can_have_its_own_colour"] notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches (with a placed text selected it resizes that text too). A Border control (none to 3 pt) frames new text or the selected text, in the text's colour or a Border colour of its own; the box grows outwards so the text keeps its place. With a placed text selected, the border controls show its own border and border colour (also after Comment Properties changed them) and change only that text; without one they set new text's until PdfCraft quits. Make Current Properties Default on typed text (its menus, or the box in Comment Properties) sets what new text gets, its size and border, kept across launches. Text boxes and callouts take a border colour too (quick bar, Comment Properties), the stroking colour in /DA. fill_sign_add accepts font_size, border and border_color for text and dates; comment_edit border_color." +[[feature]] +id = "comment.text-border-shape" +title = "An oval or a circle around a text box's or typed text's text" +area = "E" +tier = "P2" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_add", "comment_edit", "fill_sign_add", "comment_list"] +evidence = ["crates/annot/src/tests.rs::text_boxes_take_an_oval_or_a_circle_around_their_text", "crates/annot/src/tests.rs::a_circle_goes_snug_around_its_text_and_follows_it", "crates/annot/src/tests.rs::a_circle_touching_its_text_box_keeps_following_its_text", "crates/ui-egui/tests/it/fill_sign.rs::typed_text_takes_an_oval_or_a_circle_from_the_toolbar", "crates/ui-egui/tests/it/comments.rs::text_box_properties_choose_the_border_shape", "crates/automation/tests/it/automation.rs::text_borders_take_an_oval_or_a_circle_through_the_tools"] +notes = "Comment Properties ▸ Border shape (text boxes, typewriter text) and Fill & Sign's toolbar beside Border (typed text and dates, for new text and the selected one, and showing the selected one's) choose Rectangle, Oval or Circle: a position number in a circle, a label in an oval on a plan. The text box keeps its place and the shape goes around it, centred: an oval through its corners (√2 its size), a point clear of the border; the text is centred in it. A circle goes snug around its text: the text box is no wider than its text needs (typed text with a circle skips the 40 pt a box to type into keeps; choosing Circle shrinks an existing box to its lines), the circle a point clear of the text's lines and touching the box's longer sides, so a 10 pt \"3\" gets a 20 pt circle; a snug circle follows its text and font size, one made larger by hand keeps its size. /Rect grows to hold the shape and /RD keeps the text box inside it (§12.5.6.6, as a callout's; 0 where a circle touches the box, a hair below 0 read as 0), with PdfCraft's /PCBorderShape; background and border take the shape, the appearance draws it so every viewer shows it, and an app that redraws the comment from its keys draws a rectangle. Resizing the frame takes the text box along (a circle's keeping its proportions); a thicker border leaves the text where it is. Make Properties Default carries it into the Text box tool, and on typed text into Fill & Sign's new text. Headless: comment_add / comment_edit / fill_sign_add `border_shape` (rectangle, oval, circle); comment_list reports an oval or a circle. Callouts keep a rectangle. Acrobat's text boxes have a rectangular border only." + [[feature]] id = "comment.text-box" title = "Text box comment with rich text" From 6f63f98717aab80d4f9575b74fc72fecac5dc141 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:10:37 +0200 Subject: [PATCH 14/43] docs: add comparative PDF workflow research reference (#2940) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- ROADMAP.md | 1 + docs/research/pdf-workflows/README.md | 73 +++ .../pdf-workflows/competitive-research.md | 211 +++++++++ .../pdf-workflows/feature-mapping.csv | 125 +++++ .../pdf-workflows/interaction-evidence.md | 143 ++++++ .../pdf-workflows/source-register.csv | 181 ++++++++ .../pdf-workflows/workflow-reference.md | 430 ++++++++++++++++++ docs/roadmap.md | 2 + docs/ui-parity.md | 2 + 9 files changed, 1168 insertions(+) create mode 100644 docs/research/pdf-workflows/README.md create mode 100644 docs/research/pdf-workflows/competitive-research.md create mode 100644 docs/research/pdf-workflows/feature-mapping.csv create mode 100644 docs/research/pdf-workflows/interaction-evidence.md create mode 100644 docs/research/pdf-workflows/source-register.csv create mode 100644 docs/research/pdf-workflows/workflow-reference.md diff --git a/ROADMAP.md b/ROADMAP.md index f104ee0d80..efe122a8b3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -339,6 +339,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M12, #3673):** Character encoding accepts Identity-H/V fonts with Adobe CJK collections and recognizes predefined non-Identity CMaps independently. Unknown CMaps and foreign registries remain refused; synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph mapping validation and embedded-font extraction remain separate. Estimates unchanged. - **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged. - **2026-10-19 (M6, text field table):** Prepare a form ▸ Text field table drags out an area and adds rows and columns of named text fields as one undoable edit, laid out on the page as displayed, with a live preview, per-column name patterns and widths, and refusal before anything is added when a cell would be too small or a name taken; `form_add_field_table` does the same headlessly. `form.multiple-copies` is partial: copying an existing field into a grid is still missing. Estimates unchanged. +- **2026-10-17 (research):** Added a [PDF workflow research reference](docs/research/pdf-workflows/README.md): dated public-source locators, 20 workflow research domains, a pinned feature mapping and interaction evidence. Reference documentation; no feature statuses, milestone priorities or estimates changed. - **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit`, `text_edit_layout` and `page_render`'s `hide_text_block` find it again by its text and box, and refuse one that was merged into a neighbour (the edited paragraph included) instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508). - **2026-10-19 (M7, #3591):** A background added to a page that already has one is painted over the old one instead of going under it and staying hidden. Behind the content, backgrounds come first, in the order added, and then behind-content watermarks, so a watermark added after a background no longer disappears. Synthetic regressions in `crates/edit` check the order. Estimates unchanged. diff --git a/docs/research/pdf-workflows/README.md b/docs/research/pdf-workflows/README.md new file mode 100644 index 0000000000..32a855a570 --- /dev/null +++ b/docs/research/pdf-workflows/README.md @@ -0,0 +1,73 @@ +# PDF workflow research reference + +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Status:** reference documentation. Feature and roadmap snapshot: [`924c4da4`](https://github.com/storytold/pdfcraft/tree/924c4da4ac2879ac9107c6c82e20ac6be5570f0a), dated 2026-10-16. Detailed code inspection remains pinned to `3f044b62`, dated 2026-10-13. + +This reference consolidates archived comparative research, public source locators and +PdfCraft feature evidence. It separates documented workflows, historical observations, +evaluation criteria and current feature records so contributors can assess the collected +material without treating an old research claim as a present product fact. + +The documentation is independent of feature implementation. It introduces no new project +roadmap, implementation assignments, delivery estimates or feature-completion claims. +Planning remains in [ROADMAP.md](../../../ROADMAP.md) and the +[feature checklist](../../../parity/acrobat-features.toml). + +## Contents + +| Reference | Contents | +|---|---| +| [Comparative research](competitive-research.md) | Nineteen product studies and eighteen workflow families, with evidence limitations and archived corrections. | +| [Source register](source-register.csv) | 180 unique public locators, their archive dates, recovered provenance and explicit verification states. | +| [Workflow reference](workflow-reference.md) | Twenty research domains: existing foundations, retained findings and evaluation dimensions. | +| [Feature mapping](feature-mapping.csv) | 124 existing parity IDs with their exact status and milestone at the pinned snapshot, linked to the research domains. | +| [Interaction evidence](interaction-evidence.md) | Inspected document ownership, job outcomes, save, keyboard and review foundations, distinguished from historical evaluation criteria. | + +## Evidence and dates + +The archive includes sixteen product studies, a community-demand sample, an expanded +desktop/platform survey and interface investigations. Research dates span July–October +2026. Three additional desktop products appear in the expanded survey. The source register +is a navigation aid rather than independent proof of any product's current behavior. + +`archived-not-revalidated` means that the locator was recovered from a dated study; its +supporting passage and current edition have not been checked for this handoff. +`locator-reconstructed-not-revalidated` means that the original study named a source +without preserving its complete URL. Neither state means a web-archive capture exists. +Vendor-hosted community posts remain community reports, not vendor policy statements. + +Community snippets describe questions and reported pain points. They do not establish +market share, incidence or representative satisfaction. Historical pricing, licensing, +legal, security and superiority assertions are excluded. No competitor was installed or +benchmarked for this documentation contribution. + +## Reconciliation with PdfCraft + +Earlier research described measurement, a command palette, visual comparison, Tags panels +and reusable actions as missing. The inspected source and newer checklist already contain +those foundations, including synchronized side-by-side comparison and recursive folder +batches. The workflow reference records those corrections and reuses the existing oracle +tools rather than describing a second implementation. + +The dated mapping is not another live checklist. Current status comes from +`parity/acrobat-features.toml`; the ranked priorities and progress remain in +[gaps.md](../../gaps.md), [roadmap.md](../../roadmap.md) and +[ui-parity.md](../../ui-parity.md). An existing inconsistency in knockout-rendering +evidence is identified without changing its recorded status. + +## Publication and verification scope + +These are newly written summaries and public source links. No third-party screenshots, +visual designs, source code, fonts, sample documents, private paths, account data or raw +session logs are included. Historical implementation classes, toolkit choices and old +effort estimates are not adopted as PdfCraft architecture. + +Static checks cover the feature IDs, statuses, milestone assignments, reference IDs and +local links. Evaluation scenarios are research criteria, not tests already executed. +This handoff claims no native acceptance, screen-reader verification, external-reader +compatibility result, vendor benchmark or compliance certification. + +## Revision history + +| Date | Change | +|---|---| +| 2026-10-17 | Consolidated the archived research, source register, workflow evidence and pinned feature mapping. | diff --git a/docs/research/pdf-workflows/competitive-research.md b/docs/research/pdf-workflows/competitive-research.md new file mode 100644 index 0000000000..f5dc1400dc --- /dev/null +++ b/docs/research/pdf-workflows/competitive-research.md @@ -0,0 +1,211 @@ +# Comparative PDF workflow research + +Prepared 2026-10-17 from a September–October 2026 research archive. This reference summarizes product coverage, workflow distinctions and evidence limits. It is not a current competitor parity assessment, implementation specification or additional roadmap. + +## Scope and evidence rules + +The archive contains sixteen product studies dated September 7–8, a community-demand study, a September 8 synthesis with September 20 reconciliation, September 25 desktop/web/platform and interaction studies, and detailed design investigations through October 8. Three additional desktop products were examined on September 25: Power PDF, PDFelement and SumatraPDF. The accompanying [source register](source-register.csv) contains 180 distinct public source locators recovered from this material or reconstructed from its source titles. + +The product sheets combined documented capabilities, approximate edition/version history, secondary reviews, inference and historical implementation observations. These categories have different evidentiary limits: + +| Evidence category | Information retained | Limit | +|---|---|---| +| Archived primary documentation | A dated vendor-described workflow or technical interface | Not independent reliability evidence, a current edition guarantee or proof of superiority | +| Archived release notes | A reported change in a named release | Not proof of availability in every platform or edition | +| Archived source inspection | Behavior recorded at the inspected revision | Not verification of another revision, release or backend | +| Community reports and search snippets | Reported pain points and terminology | Not market share, incidence, representative satisfaction or security conclusions | +| Historical design investigation | Documented problem, constraints and evaluation dimensions | Not PdfCraft implementation status, an accepted architecture or dependency approval | + +Unless explicitly updated in the register, links are **archived-not-revalidated**. An archive date is the date written in the collected study, not a web-archive capture, freshness guarantee or current product test. **Locator-reconstructed-not-revalidated** identifies a URL reconstructed from a named source rather than recovered as a complete citation. Vendor-hosted community discussions remain community evidence; their hosting does not make them vendor capability statements. + +The register is a source-finding aid, not a claim that every linked page supports every observation in this document. Its scope notes distinguish general entry pages from more specific references. No competitor was installed or benchmarked for this handoff. Screenshots, downloaded proprietary documents, user documents, implementation prompts and copied source code are not included. + +No competitor absence counts, rankings, prices, subscription assertions, security certifications, processing-retention promises or legal deadlines are reproduced as current facts. Corrections within the archive are recorded below. PdfCraft implementation status and planning remain in its existing roadmap, parity records and source evidence. + +## Product coverage + +This table is an index to the subjects examined by each archived study. It does not assert that a product uniquely provides a capability or that PdfCraft lacks it. Product editions and platforms differed within the original studies. + +| Product study | Workflow subjects recorded | Comparison dimensions used in the research | +|---|---|---| +| Adobe Acrobat | Tool contexts; staged redaction; form events; validation/fixups; reusable actions; search and structure | Review versus apply, repeated-operation settings, document structure and edition boundaries | +| ABBYY FineReader | Linked scan/text/word zoom; uncertain-item navigation; dictionaries; page verification; cross-format comparison; scheduled processing | Correction effort, source/text synchronization, review completion and recognition output | +| Foxit PDF Editor | Custom actions; search; form events; structured review; layers; region recognition; deployment policies | GUI/automation consistency, search scope, form compatibility and platform/edition differences | +| PDF-XChange Editor | Object/field properties; annotation filters; dynamic stamps; scan correction; tags; identifiers | Selection precision, property inspection, annotation interchange and reusable configuration | +| Nitro PDF Pro | Sensitive-data review; table/form extraction; Office-oriented navigation; repeated operations | Detection versus removal, structured extraction and task-level discoverability | +| Bluebeam Revu | Markup records; reusable symbols; calibrated measurements; revisions; compare and overlay | Review metadata, technical drawing scale, revision correspondence and interchange | +| Master PDF Editor | Object inspector; menus/toolbars; forms; certificate operations | Live-document inspection, field properties and advanced operations beside the page | +| UPDF | Tool access; reading comfort; cross-page operations; summaries; platform variations | Reading/editing state, page-spanning work and platform capability differences | +| PDF Expert | Focused reading/editing; thumbnail operations; customizable access; signature placement | Page-centered work, document manipulation and contextual properties | +| PDFgear | Task-led entry; region extraction; conversion | Goal-oriented entry, region selection and output artifacts | +| Sejda | Verb-first tools; per-operation limits; filename templates; chains; unsupported-input explanations | Prerequisites, output naming, operation chaining and declared limits | +| iLovePDF | Task grid; chained processing; page/conversion utilities; desktop/web differences | Single-task entry, result reuse and processing-location disclosure | +| PDF24 | Local utilities; scanning/recognition; compression readouts; batch options; hybrid invoices | Measured outcomes, repeated processing and embedded business data | +| Smallpdf | Narrow task flows; conversion; signing categories | Entry friction, option disclosure and task-specific outputs | +| LibreOffice Draw | Import/export; headless conversion; command options; accessibility/export settings | Native editing versus conversion, semantic preservation and format tradeoffs | +| Okular | Keyboard annotation; view history; reading transforms; backend capabilities; signatures | Review navigation, reading state and unsupported-format reporting | +| Power PDF | Office-oriented navigation; deployment differences; refreshed entry surfaces | Navigation, product/edition boundaries and installation context | +| PDFelement | Customizable tool access; reusable annotations; table/form/document workflows | Repeated-action access and grouping of related operations | +| SumatraPDF | Minimal chrome; searchable commands/navigation targets; keyboard reading | Discoverability, reading navigation and command access | + +Public entry points include [Acrobat help](https://helpx.adobe.com/acrobat/user-guide.html), [FineReader help](https://help.abbyy.com/en-us/finereader/16/), [Foxit user guides](https://www.foxit.com/resource-hub/user-guide/), the [PDF-XChange feature catalogue](https://www.pdf-xchange.com/product/pdf-xchange-editor), [Bluebeam markup guidance](https://support.bluebeam.com/revu/how-to/track-and-manage-markups-using-markups-list.html), and [SumatraPDF command-palette documentation](https://www.sumatrapdfreader.org/docs/Command-Palette). Their archive or reconstruction states are recorded in the register; listing them here does not assert fresh product verification. + +## Community evidence and its limits + +The September community study used sixteen targeted searches across PDF, administration, legal, archival and related communities. Direct thread retrieval was frequently blocked, so many observations came from indexed snippets. Counts in that study represented distinct surfaced threads with unknown search coverage and overlapping audiences. They were not survey percentages. No thread-frequency ranking or verbatim snippet quotation is reproduced here. + +Five recurring themes were recorded: + +| Theme in the collected material | Distinction expressed by the reports | Evidence limit | +|---|---|---| +| Redaction and saving | Visual covering versus content removal; saved artifact versus on-screen appearance | Reports did not independently establish removal correctness across products | +| Confidential and repeated processing | Processing location, input/output predictability and reuse of options | Search results did not establish representative privacy preferences | +| Layout damage during editing | A small intended change versus font substitution or wider reflow | Individual complaints did not establish defect rates or comparative fidelity | +| Form interactivity | Filling, authoring, flattening, printing and data interchange | Similar terminology did not imply identical field or script compatibility | +| Large and mixed-result jobs | Overall progress versus per-file success, failure and unchanged output | Reported jobs were not a controlled performance benchmark | + +Ownership expectations, interruptions, keyboard review and readable themes also appeared in the collected material. They remain historical observations, rather than current pricing conclusions or statistically measured demand. + +## Workflow families examined + +The archive grouped PDF work into the following eighteen families. The dimensions below describe what the studies compared or distinguished. They do not introduce new acceptance contracts or implementation assignments for PdfCraft. + +### 1. Reading, navigation and search + +The viewing research distinguished page content from temporary reading state: zoom, viewport rotation, reading colors, navigation history and validation indicators. Search and bookmark movement were examined in relation to page labels, physical page numbers and returning to the prior reading context. + +The studies treated current-document search, cross-document search, folder indexing and recognition-backed search as different scopes. Saved sessions and view history were also distinct from saved document edits. Historical comparisons of search depth were documentation-based, not measurements of retrieval accuracy or speed. + +### 2. Text and object editing + +The editing studies separated replacement of existing text, added overlays, image/object changes and whole-page conversion. Font substitution, subset fonts, positioned text, unsupported glyphs, rotation and mixed writing systems were recurring explanations for unexpected editing results. + +The archive evaluated fidelity in terms of both appearance and extractable text. It also examined object bounds, selection properties, reflow disclosure and external-editor round trips. Reports of formatting damage did not establish a product-wide defect rate, and a single successful font/script example did not establish general CJK or right-to-left editing support. + +### 3. Page organization and composition + +Page studies covered selection, insertion, extraction, reordering, reversal, splitting, interleaving and assembly from multiple documents. They distinguished thumbnail position from page identity and distinguished a displayed page label from its physical index. + +Recorded evaluation dimensions included output-name previews, split boundaries, unequal source lengths, page-size differences, retained links/bookmarks/forms and the relationship between undo and selection. The archived cross-document staging layouts were design explorations; they are not adopted interface requirements or evidence of a current PdfCraft gap. + +### 4. OCR and human verification + +The recognition research examined the correction loop around recognition results: source image, recognized text, magnified region, uncertain-item navigation, replacement, skipping and human-reviewed state. The FineReader study emphasized synchronization and repetitive keyboard navigation rather than confidence coloring alone. + +Engine confidence and human review were separate concepts. The archive also distinguished an edited preview from a corrected searchable text layer, region recognition from whole-page recognition, and already-text input from scanned input. Language selection, preprocessing, alternate suggestions, page orientation and failed/cancelled recognition were recorded as separate dimensions. Ensemble architecture and language counts were not independent accuracy evidence; no held-out comparative accuracy result is established by this handoff. + +### 5. Forms and document scripts + +Forms research separated authoring, filling, formatting, validation, calculation, flattening and data interchange. Field lists, tab order, calculation order and event dispatch appeared in the archived comparisons. A populated field and a functioning calculation/event model were not treated as interchangeable evidence. + +The historical script investigations recorded runtime compatibility and security constraints: allowed events and APIs, resource bounds, event recursion, external access and non-executing inspection. Those investigations evaluated another implementation's options. They do not select a runtime, authorize a dependency or establish PdfCraft script behavior. Unsupported fields and unsupported events were distinct from a document that had been verified to execute correctly. + +### 6. Comments, markups and review data + +Annotation studies examined both visible appearance and review records: author, subject, page, status, replies, timestamps and summaries. Bluebeam's study devoted particular attention to structured markup lists, reusable symbol sets and drawing-review metadata. Other studies covered annotation filters, stamps, comment summaries and import/export. + +The archive distinguished filtering from deletion, literal stamp content from values inserted at placement, and standard PDF relationships from application-specific metadata. Interchange and CSV export were considered separately from the appearance of the annotation in its originating application. Specialized construction-review workflows were a distinct research category rather than a general requirement for every PDF editor. + +### 7. Redaction and sanitization + +The collected material separated candidate detection, human review, application of removal, sanitization and verification. Search patterns and entity detection concerned finding possible sensitive material; they did not themselves establish removal. Visual black fill, blur and pixelation were distinguished from removal of the underlying PDF content. + +Recorded assurance dimensions included saved-file extraction, affected images, metadata, annotations, attachments, layers, previous revisions and nested content. Geometry on rotated pages and partial-word cases were separate concerns. The reports also noted that an audit artifact can leak the very content being removed. Claims of uniquely verifiable or universally safe redaction are not reproduced; the archive is not a security certification. + +### 8. Encryption, permissions and hostile-document handling + +Security studies distinguished open-password encryption, permission flags and recipient-certificate encryption. They also examined external links, launch actions, scripts, attachments and processing of malformed files. These are different controls and do not have the same threat model. + +The archive's evaluation dimensions included credential handling, failure/refusal behavior, resource limits and consistency between the displayed operation and its execution policy. PDF permission flags were not equivalent to preventing arbitrary software from extracting accessible content. Historical algorithm and certification references remain source locators, not current conformance findings. + +### 9. Digital signatures and signing requests + +Signing research distinguished a drawn appearance, certificate-based signing, document certification, permitted changes and validation. Integrity of signed bytes, signer trust, timestamps, revocation evidence and later document changes were separate observations. A displayed validation result was tied to the document revision inspected. + +The archived local-request investigation examined prepared documents, manifests, handoff and inspection of returned files. That historical design was not a hosted signing service or identity-verification system. The archive also distinguished the local embedding of validation evidence from retrieval that may require network access. Its PAdES, dependency and legal terminology does not establish PdfCraft's attained signature profiles or the legal status of an output. + +### 10. Document comparison + +Comparison studies separated text, raster/visual, page-structure and annotation differences. They examined page correspondence after insertions, removals, duplication and reordering, together with linked scrolling, change lists, filtering, navigation and report export. + +An overlay and a textual diff supplied different observations; neither alone established comprehensive equivalence. Scanned comparison introduced recognition dependence. The archive also recorded uncertain alignment and the distinction between a filtered view, a complete report and a user-reviewed subset. No comparative false-positive or missed-change rate was measured for this handoff. + +### 11. Batch processing and presets + +The detailed batch investigations recorded ordered steps, versioned preset data, option validation, output naming, conflicts, stop/continue policy, numbering continuity and per-file reports. They distinguished inert configuration data from executable scripts or shell commands. + +Atomicity was an explicit research dimension: a per-file chain could be all-or-nothing while a whole run had mixed outcomes; a whole-run transaction was a different guarantee. Cancellation could leave previously committed work, depending on the documented boundary. The archive also examined watch-folder repetition, result retention and deterministic numbering when files completed in a different order. These historical designs are not a replacement action format or another batch runner for PdfCraft. + +### 12. Conversion, extraction and document fidelity + +Conversion studies distinguished extracted PDF text, reconstructed document structure, page images, editable Office output and tabular data. A file extension, package structure check and visual rendering each supplied different evidence. Table boundaries, cell order, merged cells, page ranges and output settings were recorded separately. + +The archived fidelity questions concerned retained bookmarks, annotations, attachments, form semantics and unsupported content as well as visual layout. The studies also noted that exported text can be interpreted as spreadsheet formulas. Historical claims about conversion breadth or editable output were not independent measurements of arbitrary-document fidelity. + +### 13. Compression, fonts and color + +Optimization studies separated lossless cleanup, image recompression, resolution reduction, font subsetting and color conversion. Estimated savings and measured before/after bytes were different quantities. An output larger than its input was still a possible result of processing. + +The October font investigation documented that a control's name alone did not establish actual subsetting for every font class in the implementation studied. The color investigation examined decoder behavior, embedded profiles, image representations and rendering intent. Their findings are historical case studies, not PdfCraft defect reports or dependency choices. Rendering, selectable text, font handling and retention of associated files were independent evaluation dimensions. + +### 14. Printing, measurements and preflight + +Printing research covered page order, N-up, booklet, duplex, comments and preview. Measurement research distinguished viewport scale, physical page geometry, calibration, units and stored annotations. Rotated pages and nonzero page-box origins affected the interpretation of geometric results. + +Preflight, fixups and production previews were separate categories. A check reported an observation; a fixup changed the document; a production preview concerned the intended output process. The archive examined named profiles and skipped checks without treating a collection of lightweight checks as a complete production or standards validator. + +### 15. Accessibility and archival formats + +The archive distinguished accessible application operation from accessible PDF output. For the application, it examined keyboard paths, focus, labels, status, contrast, custom-painted controls and actual assistive-technology behavior. For the document, it examined tags, reading order, alternative text, language, lists, tables and the relationship between content identifiers and structure. + +The historical tagging investigation bounded the document classes it considered and separated automatic classification from human review. A structure check, a screen-reader session and a standards validator reported different forms of evidence. Automated checks did not by themselves establish document accessibility or legal compliance. The register retains standards references without reproducing the archive's changing legal-deadline assertions. + +### 16. Layers, attachments and structured business documents + +Layer research separated visibility, printing state, membership, ordering and properties. Displaying a layer panel was not evidence that every optional-content transformation was supported. Hidden content also had implications for searching and sanitization. + +The archive treated attachments and embedded XML as potentially essential document data. Hybrid-invoice discussions distinguished detecting and preserving a payload from reading it, validating it, editing it or generating a compliant invoice. Those were separate scopes; the handoff carries no legal mandate, invoice-conformance finding or current product absence assertion. + +### 17. Deployment, automation and integration + +Integration research examined menus, keyboard access, contextual commands, command search, CLI operations, structured automation and watched folders. They were access paths to operations, not equivalent guarantees about authorization, cancellation or execution policy. + +The archive recorded coordinate naming, document identity, input/output paths, confinement, authorization, limits and headless reporting as evaluation dimensions. Silent installation, managed policies, portable builds, platform signing and architecture support were additional deployment subjects. Descriptions of other applications' registries or toolkits do not establish a new architecture for PdfCraft. + +### 18. Assisted recognition and grounded document answers + +The expanded survey covered suggested sensitive entities, image descriptions, extracted structures and answers about document content. It distinguished suggestions from accepted edits, source-grounded answers from unsupported statements, and processing location from the appearance of the interface. + +The studies recorded language coverage, available runtimes, network behavior, source-page references and editable suggestions as evaluation dimensions. They did not supply held-out accuracy measurements, an approved dependency selection or proof that generated descriptions established accessibility. Sensitive-data suggestions and actual redaction remained different stages. + +## Cross-cutting interface observations + +The September 25 interaction studies recorded several recurring dimensions across product families: + +| Dimension | Distinctions recorded in the archive | +|---|---| +| Command access | A command's identity, its entry points, its availability and its explanation when unavailable | +| Task surface | One-shot configuration, repeated review, live-document inspection and long-running monitoring | +| Document context | Active tab versus operation owner; current document versus the revision analyzed | +| Result state | Preview, pending mutation, completed edit, saved output, stale report and partial result | +| Focus and navigation | Entering a tool, moving between related views and returning to the prior reading position | +| State communication | Color, text, shape, control state and assistive-technology semantics | +| Layout and scaling | Narrow windows, large text, display scales, long translations and secondary-panel visibility | +| Visual consistency | Icon meaning, labels, themes and the difference between page colors and application chrome | +| Long operations | Start, cancellation request, partial completion, error, retry and completed output | + +The archive included sketches of persistent OCR/comparison views and inspectors. Those sketches are historical research artifacts, not adopted navigation plans, prescribed workspace layouts or new implementation requirements. No third-party visual design is imported here. + +## Corrections and unresolved evidence in the archive + +Several later reports qualified earlier claims. The following distinctions prevent an archived comparison from becoming an inaccurate current scoreboard: + +1. **Licensing:** later research qualified an early universal subscription assertion. Product licensing varied by edition and date. No current price or subscription conclusion is carried forward. +2. **Shipping evidence:** later inspection distinguished a low-level engine capability, a visible entry point and tested end-to-end behavior. They were not equivalent evidence of a complete product workflow. +3. **Signing terminology:** appearance, certification, long-term validation evidence and qualified-signature services were conflated in parts of the earlier material. This reference treats them separately. +4. **Absence claims:** some negative findings came from documentation searches or snippets rather than exhaustive product tests. They are not reproduced as current facts. +5. **Accuracy and security:** architecture and feature lists were sometimes used to infer recognition accuracy or removal reliability. This handoff records no such superiority or safety conclusion. +6. **Regulatory references:** accessibility and invoicing deadlines were corrected within the archive. No legal deadline or compliance conclusion is asserted here. +7. **Implementation transfer:** toolkit-specific source paths, proposed dependencies and defects from another codebase did not establish PdfCraft behavior. This document retains workflow distinctions and historical observations, not those implementation directions. + +Current PdfCraft planning and feature status remain in the existing roadmap and parity records. The source register and this reference document only describe the collected research and its limits. diff --git a/docs/research/pdf-workflows/feature-mapping.csv b/docs/research/pdf-workflows/feature-mapping.csv new file mode 100644 index 0000000000..d2daefabe7 --- /dev/null +++ b/docs/research/pdf-workflows/feature-mapping.csv @@ -0,0 +1,125 @@ +domain,research_topic,parity_id,status,milestone,snapshot_commit,feature_evidence_source,research_reference,source_register,source_inspection_commit,evidence_limit,evidence_note +B01,Rendering and output fidelity,view.render-pages,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-shadings-patterns,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-transparency,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-knockout,shipped,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Recorded shipped status conflicts with signing-related notes/evidence and transparency notes; maintainer evidence reconciliation required; no status changed here +B01,Rendering and output fidelity,view.render-type3,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-cid-fonts,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-image-codecs,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B02,Existing-text editing,edit.text-edit,partial,M7,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B02,Existing-text editing,edit.rtl-cjk-editing,partial,M7,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B02,Existing-text editing,edit.rotated-text-editing,planned,M7,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B02,Existing-text editing,edit.keep-tags-on-edit,planned,M7,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-text,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-visual,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-report,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-side-by-side,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-filters,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-page-ranges,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-scanned,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.correct-recognized-text,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.editable-text-output,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.recognize-text,shipped,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.recognize-range,shipped,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.recognize-multiple,shipped,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.ocr-languages,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.rotation-detection,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.deskew,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.ocr-engines,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.despeckle,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.background-removal,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.camera-perspective,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B06,Reusable batch processing,misc.action-wizard-run,shipped,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B06,Reusable batch processing,misc.action-wizard-create,shipped,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B06,Reusable batch processing,misc.action-wizard-manage,planned,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B06,Reusable batch processing,misc.action-prompt-steps,planned,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.autotag,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.tags-panel,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.tag-properties,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.reading-order-tool,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.order-panel,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.table-editor,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.create-tag-from-selection,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.artifact-marking,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.pdfa-validate,partial,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.pdfa-convert,partial,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.preflight,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.preflight-results,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.preflight-reports,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redaction-verification,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-glyph-removal,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-image-pixels,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-vectors,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-annots-fields,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-struct-tree,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-pattern-locales,planned,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-partial-words,planned,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-folder,planned,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redaction-properties,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redaction-codes,partial,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.cades-pades-bb,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.pkcs12,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.windows-cert-store,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.macos-keychain,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.certify,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.timestamp-servers,planned,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.pkcs11,planned,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.field-mdp,planned,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.pades-bt,partial,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.ltv,partial,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.pades-blta,partial,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.ocsp-crl,partial,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.custom-scripts,shipped,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.event-order,shipped,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.calculation-order,shipped,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.submit-form,planned,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",edit.action-javascript,planned,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.push-buttons,partial,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-distance,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-scale,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-perimeter,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-area,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-snap,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-info-panel,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-export,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.combine-files,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.combine-choose-pages,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.combine-sort,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.combine-thumbnails,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.field-name-conflicts,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.split-every-n,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.split-by-bookmarks,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.struct-tree-merge,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.split-multiple-files,planned,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.split-naming,planned,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B14,Creation and conversion fidelity,create.from-office,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B14,Creation and conversion fidelity,create.export-docx,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B14,Creation and conversion fidelity,create.export-xlsx,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B14,Creation and conversion fidelity,create.export-pptx,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B15,Document performance and resource use,view.large-document-performance,shipped,M3,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B15,Document performance and resource use,misc.performance-budgets,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B15,Document performance and resource use,core.lazy-loading,planned,M1,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B16,Search across document collections,view.advanced-search,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B16,Search across document collections,view.search-folder,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B16,Search across document collections,view.search-boolean,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B16,Search across document collections,view.search-index,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B17,Keyboard and assistive-technology interaction,a11y.app-screen-reader,partial,M3,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B17,Keyboard and assistive-technology interaction,a11y.keyboard-only,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B17,Keyboard and assistive-technology interaction,a11y.replace-page-colors,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B18,Preferences and capability disclosure,misc.preferences-dialog,partial,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B18,Preferences and capability disclosure,misc.prefs-search,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B18,Preferences and capability disclosure,misc.admin-preferences,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B18,Preferences and capability disclosure,misc.rtl-ui,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,organize.print-presets,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,print.output-preview,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,print.color-warnings,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,print.convert-colors,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,print.color-management,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.undo-redo,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.undo-after-save,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.atomic-batch-edits,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.autosave,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.crash-recovery,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.unsaved-changes-prompt,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.web-recovery,planned,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",view.multiple-windows,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package diff --git a/docs/research/pdf-workflows/interaction-evidence.md b/docs/research/pdf-workflows/interaction-evidence.md new file mode 100644 index 0000000000..7773505747 --- /dev/null +++ b/docs/research/pdf-workflows/interaction-evidence.md @@ -0,0 +1,143 @@ +# Interaction evidence and evaluation reference + +Detailed source inspection used PdfCraft revision +`3f044b6263aa6605c3aa44ca6d065e1ab37d13b6`. A later documentation review used +`924c4da4ac2879ac9107c6c82e20ac6be5570f0a` on 2026-10-17. Links retain those +inspected revisions. Neither review was a runtime parity assessment. + +This reference summarizes interaction findings from archived comparative research and +relates them to existing PdfCraft mechanisms. It contains no implementation plan, +proposed workspace design, feature commitment or new architecture. The project's +roadmap and machine-readable parity checklist retain their planning and status roles. +Historical design examples and test descriptions are distinguished from current product +evidence below. + +## Existing mechanisms and corrected historical descriptions + +Several early comparative notes described capabilities that later existed in PdfCraft. +The table records the inspected evidence and its limits rather than preserving those +absence claims. + +| Area | Inspected evidence | What that evidence establishes | +|---|---|---| +| Command discovery and dispatch | [`commands.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/commands.rs), [`palette.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/palette.rs) | Menus, shortcuts, palette and other entry points share command metadata/dispatch. The palette is an existing feature, not an absent research concept. | +| Measurement | [`measure_ui.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/measure_ui.rs) | Distance/perimeter/area controls, calibration, snapping and saved-measurement state exist. An older session-only calibration description does not describe every current persisted scale. | +| Comparison | [`compare_ui.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/compare_ui.rs) | Text changes, visual regions, document highlights, report and comment actions exist. This older source link does not establish all later split-view behaviour. | +| OCR | [`ocr_ui.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/ocr_ui.rs) | Background recognition, cancellation request and multiple-file processing exist. Cancellation can retain pages already recognized; it is not synonymous with rollback. | +| Accessibility review | [`accessibility.rs` tests](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/tests/accessibility.rs) | Source includes checks for finding navigation, Tags-panel review, automatic text tagging and per-document skipped-rule state. Tests were inspected, not executed for this handoff. | +| Interface appearance | [`theme.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/theme.rs) | A shared token type and System/Light/Dark preferences exist. Their presence is not a measured contrast result. | +| International text input | [`commands.rs` tests](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/tests/commands.rs) | Palette tests cover idle composition frames, Chinese preedit/commit, cancellation and outside-click behaviour. This does not establish native input-method behaviour in every editable field. | +| Automation conventions | [`automation/README.md`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/automation/README.md) | The documented contract uses displayed top-left rectangles, 1-based pages/positions, confined paths and a shared tool table. Historical mixed-coordinate criticism is not current evidence of a defect. | + +The later [feature checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) +marks synchronized side-by-side comparison shipped. Its notes describe two document +views when the older version remains open and the window is at least 1000 pixels wide; +synchronization follows offsets rather than matching content. The record identifies +page-size drift and old-tab closure as limits. It also records Action Wizard +creation/editing/running and recursive CLI/MCP folder batches with bounded recipes, +relative paths, collision-safe output names and per-file reports. + +The later [roadmap](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/ROADMAP.md) +records changed-on-disk Save confirmation and a shared staging-file writer. It describes +new outputs being reopened/checked before publication. These are existing safety +mechanisms; the archived research is not evidence that atomic saves or conflict +notifications are absent. + +Some prose gap tables at the same revision still describe comparison or Tags panels as +missing, while detailed checklist entries and inspected source describe implementations. +This is a documentation inconsistency in the inspected snapshot, not evidence that the +code is absent or that its listed limitations are solved. + +## What the archived interaction research distinguished + +The research compared workflow structures rather than implementation languages. Its +recurring distinctions are useful when reading feature descriptions and test evidence: + +- **Document identity versus current selection.** A tab index, display name and filesystem + path describe different things. Changing the selected tab does not change which source + a previously started operation analyzed. Reopening the same path can create a different + session, and an edit can change the revision within one session. +- **Analysis versus edit versus export.** A finding list describes an input revision; a + preview can remain uncommitted; an export can describe an invocation snapshot. The + existence of a preview does not establish that document bytes changed or a file saved. +- **Cancellation request versus terminal cancellation.** A request can be pending while + a worker reaches a boundary. A completed subset may be intentionally retained. An + atomic commit and the surrounding multi-file run have different interruption scopes. +- **History availability versus restoration outcome.** A named undo entry establishes + that history exists. Successful restoration, rendering refresh and save/reopen are + separate observable outcomes. The research did not reproduce a PdfCraft history failure. +- **Atomic replacement versus destination conflict.** Staging avoids half-written output; + conflict detection deals with another writer changing the intended destination. They + address different failure modes. The roadmap records mechanisms in both categories. +- **Automated finding versus conformance.** A checker pass describes supported checks. + Manual review, unsupported constructs and a report's input identity affect how that + result can be interpreted. A pass is not independent certification. + +These distinctions are an interpretation vocabulary, not a checklist of newly required +features. The underlying product's declared behaviour determines whether retaining a +partial result or refusing an operation is the expected outcome. + +## Evaluation observations by workflow + +The following table describes what the archived evaluations observed or separated. It +neither reports a fresh PdfCraft test run nor assigns implementation tasks. Example +conditions describe evidence scope rather than newly discovered bugs. + +| Workflow | Observable relationship examined | Limits on interpreting the observation | +|---|---|---| +| OCR review | The recognized word, source region, selected row and magnified crop referred to the same token; a saved correction was distinguished from text displayed only in a preview. | Confidence was backend evidence, not a universal accuracy score. Correcting invisible searchable text and visibly editing a scanned phrase were distinct operations. | +| Comparison | Original/revised direction, selected change, displayed pages and exported report referred to the same comparison. Filtering and review disposition were distinguished from the full result set. | Equal text did not establish equal appearance or structure. Offset synchronization and content correspondence were distinct. A missing corresponding page was not page zero or a valid empty match. | +| Batch processing | The input, selected action, current step, saved outputs and per-file failures were separately observable. Completed outputs remained meaningful in a partially failed run. | Per-file atomicity did not imply a whole-run transaction. A retry could repeat a previously committed effect. `command_batch` documents that earlier edits can remain after a later error. | +| Accessibility review | Findings, selected tags and page locations referred to the same reachable objects. Proposed classification and applied structure were distinguished. | An automatic structural result did not establish reading quality. Text-only tagging evidence did not cover arbitrary images, tables or existing structure. | +| Standards checks | The profile, checked revision, findings and saved conversion artifact were separate evidence fields. | Structural checks for a subset of a standard were not a full validation claim. An unavailable external validator was not a passing result. | +| Page organization | Source selection, destination identity, permutation and resulting saved page order were evaluated separately. | Successful page transfer did not by itself establish preservation of every bookmark, form relationship or structure tree. | +| Redaction review | Marked regions, committed removal and checks on the saved output were separate stages. Verification wording was tied to the supported check scope. | A visual rectangle was not removal evidence. Reports and logs could themselves expose removed content. Absence under one extraction strategy did not prove absence under all encodings. | + +Archived state-machine notes used terms such as running, review-ready, saving, complete, +partial, failed and stale. Those terms describe distinct user-visible outcomes; they do +not establish that PdfCraft has, or needs, a single global state-machine type. Likewise, +historical multi-pane layouts described a way to observe several related values at once; +they are not adopted interface designs or a request for a replacement shell. + +## Keyboard, input and appearance evidence + +The research separated semantic labels from complete task access. A headless +accessibility tree can expose a control's name, role and state without establishing that +a person can finish a keyboard-only task or that a native screen reader announces the +result clearly. Focus ownership, return focus after closing a surface, background focus +changes and non-drag alternatives were observed independently from visual arrangement. +No native screen-reader session was performed for this handoff. + +[WCAG 2.2](https://www.w3.org/TR/WCAG22/) supplies technology-neutral criteria for +keyboard operation, focus, contrast and target sizes. It is a reference, not evidence +that this native application conforms to a level. The [WAI-ARIA tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) +describes web tab focus/activation semantics; it does not determine native widget or +platform accessibility implementations. + +Input-method research distinguished preedit text, committed text, selection, undo and +cancellation. Enter/Escape can have composition meanings in addition to dialog meanings. +Chinese palette composition tests exist in the inspected source. Their presence does +not establish candidate-window behaviour on Windows, macOS or Linux, or equivalent +coverage in other fields. RTL interface direction and PDF coordinate conventions were +also separate concerns. + +Appearance research distinguished interface chrome, page content and reading-colour +transforms. Switching an interface theme did not constitute editing a PDF's colours. +Token tables described intended colours; painted states, alpha composition, focus and +selection established different evidence. Layout evaluation recorded viewport, scaling, +translation length and text direction because a layout observation at one size did not +establish results at other sizes. No archived palette, icon family, font, screenshot or +prototype layout is included or recommended by this reference. + +## Evidence boundaries + +The material includes archived rationale, source inspection, documented conventions and +proposed-test descriptions from historical research. It does not include fresh native +acceptance, manual assistive-technology checks, independently measured OCR accuracy, +rendering benchmarks or external-reader compatibility results for this handoff. + +Names of tests in linked source establish that the source contains those tests. They do +not mean the tests were rerun here, or that an archived implementation's test result +transfers to PdfCraft. The code-inspection and documentation baselines differ and remain +explicit. Current statuses and future work belong to the repository's existing roadmap +and parity records, not this evaluation reference. diff --git a/docs/research/pdf-workflows/source-register.csv b/docs/research/pdf-workflows/source-register.csv new file mode 100644 index 0000000000..558fef67a5 --- /dev/null +++ b/docs/research/pdf-workflows/source-register.csv @@ -0,0 +1,181 @@ +source_id,product,topic,url,archive_date,evidence_state,origin_id,scope_note +SR001,ABBYY FineReader,Start window and task entry (FineReader 14 guide),https://help.abbyy.com/en-us/finereader/14/user_guide/startwindow_firstpage/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR002,ABBYY FineReader,"User guide index: verification, comparison and automation",https://help.abbyy.com/en-us/finereader/16/,2026-09-07,locator-reconstructed-not-revalidated,source-title-recovery,Core study names guide chapters but not full URLs; locator reconstructed for discovery. +SR003,ABBYY FineReader,Product and feature documentation entry,https://pdf.abbyy.com/,2026-09-07,locator-reconstructed-not-revalidated,source-title-recovery,Core study identifies product documentation and brochure; root locator reconstructed. +SR004,ABBYY FineReader Engine SDK,FineReader Engine 12 SDK release notes,https://support.abbyy.com/hc/en-us/articles/37502278922642-FineReader-Engine-12-for-Windows-Release-8-Update-2,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR007,Adobe Acrobat,Acrobat product help index,https://helpx.adobe.com/acrobat/user-guide.html,2026-09-07,locator-reconstructed-not-revalidated,source-title-recovery,Core study names Help Center but not an exact URL; locator reconstructed for discovery. +SR008,Adobe Express,Adobe Express PDF editing guide,https://helpx.adobe.com/express/web/documents-and-presentations/edit-pdf.html,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR009,Adobe Express,Adobe Express release notes,https://helpx.adobe.com/express/web/whats-new/release-notes.html,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR010,Adobe Acrobat,Acrobat Studio product announcement,https://news.adobe.com/news/2026/01/adobe-acrobat-studio-transforms,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR011,Adobe Acrobat,Acrobat productivity product announcement,https://news.adobe.com/news/2026/05/adobes-new-productivity-agent,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR012,Adobe Acrobat,Acrobat JavaScript API reference,https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsapiref/index.html,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR013,Adobe Acrobat,Acrobat JavaScript developer guide,https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsdevguide/index.html,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR014,Adobe Acrobat,ISO 32000-1:2008 PDF specification,https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR015,Bluebeam Revu,Community discussion (secondary workflow evidence),https://community.bluebeam.com/discussion/363,2026-09-07,archived-not-revalidated,core-bluebeam,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR016,Bluebeam Revu,Community discussion (secondary workflow evidence),https://community.bluebeam.com/discussion/5299/about-studio-prime-end-of-life-eol,2026-09-07,archived-not-revalidated,core-bluebeam,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR017,Bluebeam Revu,Documentation locator: markups list mtv,https://support.bluebeam.com/online-help/revu20/Content/RevuHelp/Menus/Window/Panels/Markups/Markups-List--MTV.htm,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR018,Bluebeam Revu,Documentation locator: compare documents vs overlay pages,https://support.bluebeam.com/revu/features/compare-documents-vs-overlay-pages.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR019,Bluebeam Revu,Documentation locator: manage and review markups with filter list,https://support.bluebeam.com/revu/how-to/manage-and-review-markups-with-filter-list.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR020,Bluebeam Revu,Documentation locator: track and manage markups using markups list,https://support.bluebeam.com/revu/how-to/track-and-manage-markups-using-markups-list.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR021,Bluebeam Revu,Documentation locator: revu 20 eol,https://support.bluebeam.com/revu/resources/revu-20-eol.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR022,Bluebeam Revu,Release notes and change history,https://support.bluebeam.com/revu/resources/revu-21-release-notes.html,2026-09-07,archived-not-revalidated,core-bluebeam;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR023,Bluebeam Revu,Documentation locator: revu mcp,https://support.bluebeam.com/revu/resources/revu-mcp.html,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR024,Bluebeam Revu,Documentation locator: bluebeam subscription faq,https://support.bluebeam.com/revu/subscription/onboarding/bluebeam-subscription-faq.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR025,Bluebeam Revu,Documentation locator: subscription features,https://support.bluebeam.com/revu/subscription/subscription-features.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR026,Bluebeam Revu,Documentation locator: studio prime eol announcement,https://support.bluebeam.com/studio/resources/studio-prime-eol-announcement.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR027,Bluebeam Revu,Documentation locator: link,https://support.bluebeam.com/user-manual/menus/batch/link.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR028,Bluebeam Revu,Documentation locator: sign seal,https://support.bluebeam.com/user-manual/menus/batch/sign-seal.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR029,Bluebeam Revu,Documentation locator: slip sheet,https://support.bluebeam.com/user-manual/menus/batch/slip-sheet.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR030,Bluebeam Revu,Documentation locator: compare documents,https://support.bluebeam.com/user-manual/menus/document/compare-documents.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR031,Bluebeam Revu,Documentation locator: ocr,https://support.bluebeam.com/user-manual/menus/document/ocr.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR032,Bluebeam Revu,Documentation locator: overlay pages,https://support.bluebeam.com/user-manual/menus/document/overlay-pages.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR033,Bluebeam Revu,Documentation locator: erase content,https://support.bluebeam.com/user-manual/menus/edit/erase-content.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR034,Bluebeam Revu,Documentation locator: redaction,https://support.bluebeam.com/user-manual/menus/edit/redaction.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR035,Bluebeam Revu,Documentation locator: create forms,https://support.bluebeam.com/user-manual/menus/tools/create-forms.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR036,Bluebeam Revu,Documentation locator: form fields,https://support.bluebeam.com/user-manual/menus/tools/form-fields.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR037,Bluebeam Revu,Documentation locator: manage self signed ids,https://support.bluebeam.com/user-manual/menus/tools/manage-self-signed-ids.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR038,Bluebeam Revu,Documentation locator: create edit sets,https://support.bluebeam.com/user-manual/menus/window/create-edit-sets.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR039,Bluebeam Revu,Documentation locator: punch tool set,https://support.bluebeam.com/user-manual/menus/window/punch-tool-set.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR040,Bluebeam Revu,Licensing and edition reference (historical; no current price assertion),https://www.bluebeam.com/pricing/,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR041,ETSI,Documentation locator: 31914201,https://www.etsi.org/deliver/etsi_en/319100_319199/31914201/,2026-09-09,archived-not-revalidated,design-send-for-signing,"Archived source locator; supporting passage, edition and present behavior require review." +SR042,ETSI,Documentation locator: en 31914201v010201p,https://www.etsi.org/deliver/etsi_en/319100_319199/31914201/01.02.01_60/en_31914201v010201p.pdf,2026-09-09,archived-not-revalidated,design-send-for-signing,"Archived source locator; supporting passage, edition and present behavior require review." +SR043,Firefox / pdf.js,Form scripting API implementation reference,https://github.com/mozilla/pdf.js/blob/master/src/scripting_api/aform.js,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR044,Firefox / pdf.js,Documentation locator: pdf alt text,https://support.mozilla.org/en-US/kb/pdf-alt-text,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR045,Foxit PDF Editor,Documentation locator: pdf products comparison,https://cdn01.foxitsoftware.com/pub/foxit/datasheet/general/en_us/pdf-products-comparison.pdf,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR046,Foxit PDF Editor,Documentation locator: articles,https://kb.foxit.com/s/articles/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR047,Foxit PDF Editor,Documentation locator: 360040658511,https://kb.foxit.com/s/articles/360040658511,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR048,Foxit PDF Editor,Documentation locator: 4410630911764,https://kb.foxit.com/s/articles/4410630911764,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR049,Foxit PDF Editor,Documentation locator: foxit pdf editor perpetual license support policy,https://kb.foxit.com/s/articles/Foxit-PDF-Editor-Perpetual-License-Support-Policy,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR050,Foxit PDF Editor,Documentation locator: steps to upgrade to foxit pdf editor version 14 on windows,https://kb.foxit.com/s/articles/Steps-to-Upgrade-to-Foxit-PDF-Editor-Version-14-on-Windows,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR051,Foxit PDF Editor,Documentation locator: foxit pdf editor aec drawing sets update,https://www.foxit.com/blog/foxit-pdf-editor-aec-drawing-sets-update/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR052,Foxit PDF Editor,Documentation locator: foxits perpetual license support policy what you need to know,https://www.foxit.com/blog/foxits-perpetual-license-support-policy-what-you-need-to-know/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR053,Foxit PDF Editor,Documentation locator: 11200,https://www.foxit.com/company/press/11200.html,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR054,Foxit PDF Editor,Licensing and edition reference (historical; no current price assertion),https://www.foxit.com/pdf-editor/pricing/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR055,Foxit PDF Editor,Release notes and change history,https://www.foxit.com/pdf-editor/version-history.html,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR056,Foxit PDF Editor,Documentation locator: add custom commands to custom actions,https://www.foxit.com/resource-hub/user-guide/foxit-pdf-editor-for-windows/add-custom-commands-to-custom-actions/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR057,Foxit PDF Editor,Documentation locator: shared review,https://www.foxit.com/resource-hub/user-guide/foxit-pdf-editor-for-windows/shared-review/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR058,Foxit PDF Editor,Published policy or security reference (historical),https://www.foxit.com/support/refund-policy/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR059,Icon system,Icon asset provenance and licensing reference,https://github.com/google/material-design-icons,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR060,Icon system,Icon asset provenance and licensing reference,https://github.com/lucide-icons/lucide,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR061,Icon system,Icon asset provenance and licensing reference,https://github.com/microsoft/fluentui-system-icons,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR062,Icon system,Icon asset provenance and licensing reference,https://github.com/tabler/tabler-icons,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR063,Icon system,License text (historical locator; review exact version before reuse),https://raw.githubusercontent.com/lucide-icons/lucide/main/LICENSE,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR064,IETF,Documentation locator: rfc3161,https://datatracker.ietf.org/doc/html/rfc3161,2026-09-09,archived-not-revalidated,design-send-for-signing,"Archived source locator; supporting passage, edition and present behavior require review." +SR065,iLovePDF,Documentation locator: pdf web or desktop,https://www.ilovepdf.com/blog/pdf-web-or-desktop,2026-09-08,archived-not-revalidated,core-ilovepdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR066,iLovePDF,Documentation locator: desktop,https://www.ilovepdf.com/desktop,2026-09-08,archived-not-revalidated,core-ilovepdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR067,iLovePDF,Licensing and edition reference (historical; no current price assertion),https://www.ilovepdf.com/pricing,2026-09-08,archived-not-revalidated,core-ilovepdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR068,iLovePDF,Documentation locator: sitemap.xml,https://www.ilovepdf.com/sitemap.xml,2026-09-08,archived-not-revalidated,core-ilovepdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR069,KDE,Documentation locator: show bug.cgi,https://bugs.kde.org/show_bug.cgi?id=443403,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR070,KDE,Documentation locator: show bug.cgi,https://bugs.kde.org/show_bug.cgi?id=489299,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR072,KDE,Release notes and change history,https://kde.org/announcements/changelogs/gear/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR073,KDE,Documentation locator: 22.04.0,https://kde.org/announcements/gear/22.04.0/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR074,KDE,Documentation locator: 23.08.0,https://kde.org/announcements/gear/23.08.0/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR075,KDE,Documentation locator: 26.08.0,https://kde.org/announcements/gear/26.08.0/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR076,LibreOffice Draw,PDF export and security help index,https://help.libreoffice.org/latest/en-US/text/shared/01/ref_pdf_export.html,2026-09-07,locator-reconstructed-not-revalidated,source-title-recovery,Core study names export help paths without full URLs; locator reconstructed for discovery. +SR077,Master PDF Editor,Documentation locator: downloads,https://code-industry.net/downloads/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR078,Master PDF Editor,Documentation locator: faq,https://code-industry.net/faq/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR079,Master PDF Editor,Documentation locator: masterpdfeditor help,https://code-industry.net/masterpdfeditor-help/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR080,Master PDF Editor,Workspace navigation and object inspection,https://code-industry.net/masterpdfeditor-help/workspace-master-pdf-editor/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR081,Master PDF Editor,Documentation locator: masterpdfeditor,https://code-industry.net/masterpdfeditor/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR082,Master PDF Editor,Licensing and edition reference (historical; no current price assertion),https://code-industry.net/purchase/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR083,Master PDF Editor,Documentation locator: what is new in master pdf editor 5,https://code-industry.net/what-is-new-in-master-pdf-editor-5/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR084,Microsoft platform documentation,Documentation locator: 9n41msq1wnm8,https://apps.microsoft.com/detail/9n41msq1wnm8,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR085,Microsoft platform documentation,Documentation locator: xpfd51h3vqzfm0,https://apps.microsoft.com/detail/xpfd51h3vqzfm0,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR086,Microsoft platform documentation,Documentation locator: pdf reader,https://explore.microsoft.com/en-us/edge/features/pdf-reader,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR087,Nitro PDF Pro,Community discussion (secondary workflow evidence),https://community.gonitro.com/topic/12609/,2026-09-07,archived-not-revalidated,core-nitro,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR088,Nitro PDF Pro,Community discussion (secondary workflow evidence),https://community.gonitro.com/topic/21169/,2026-09-07,archived-not-revalidated,core-nitro,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR089,Nitro PDF Pro,Community discussion (secondary workflow evidence),https://community.gonitro.com/topic/22140/,2026-09-07,archived-not-revalidated,core-nitro,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR090,Nitro PDF Pro,Documentation locator: automate,https://www.gonitro.com/automate,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR091,Nitro PDF Pro,Documentation locator: bulk send and templates,https://www.gonitro.com/bulk-send-and-templates,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR092,Nitro PDF Pro,Release notes and change history,https://www.gonitro.com/documentation/release-notes,2026-09-07,archived-not-revalidated,core-nitro;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR093,Nitro PDF Pro,Documentation locator: pdf pro,https://www.gonitro.com/pdf-pro,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR094,Nitro PDF Pro,Licensing and edition reference (historical; no current price assertion),https://www.gonitro.com/pricing,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR095,Nitro PDF Pro,Release notes and change history,https://www.gonitro.com/release-hub/releases,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR096,Nitro PDF Pro,Release notes and change history,https://www.gonitro.com/release-hub/releases/sign-api,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR097,Nitro PDF Pro,Documentation locator: nitros complete guide to secure automated pdf redaction,https://www.gonitro.com/resources/nitros-complete-guide-to-secure-automated-pdf-redaction,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR098,Nitro PDF Pro,Documentation locator: sign,https://www.gonitro.com/sign,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR099,Okular,Project source and technical documentation reference,https://github.com/KDE/okular,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR100,Okular,Product or project documentation entry,https://okular.kde.org/,2026-09-07,archived-not-revalidated,core-okular,Landing/index locator only; locate a specific supporting page before citing a feature. +SR101,Okular,Documentation locator: download,https://okular.kde.org/download/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR102,Okular,Documentation locator: faq,https://okular.kde.org/faq/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR103,Okular,Documentation locator: formats,https://okular.kde.org/formats/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR104,Okular,Project source and technical documentation reference,https://raw.githubusercontent.com/KDE/okular/master/doc/index.docbook,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR105,PDF Association,Product or project documentation entry,https://pdfa.org,2026-09-07,archived-not-revalidated,core-updf,Landing/index locator only; locate a specific supporting page before citing a feature. +SR106,PDF Association,Release notes and change history,https://pdfa.org/foxit-20252-and-v14-release-highlights-faster-smarter-more-secure/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR107,PDF Association,Foxit PDF Editor 2026.2 release announcement,https://pdfa.org/foxit-pdf-editor-20262-release-notes/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR108,PDF Association,Accessible PDF standards and reference material,https://pdfa.org/iso-14289-2-pdf-ua-2-the-gold-standard-for-accessibility-in-pdf-2-0-has-arrived/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR109,PDF Association,UPDF 2.5 release announcement,https://pdfa.org/updf-25-officially-launches-worldwide-the-first-pdf-editor-powered-by-autonomous-ai-agents/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR110,PDF Association,Accessible PDF standards and reference material,https://pdfa.org/wtpdf/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR111,PDF Expert,Product or project documentation entry,https://copilot.pdfexpert.com/,2026-09-07,archived-not-revalidated,core-pdfexpert;expanded-01,Landing/index locator only; locate a specific supporting page before citing a feature. +SR112,PDF Expert,Product or project documentation entry,https://pdfexpert.com/,2026-09-07,archived-not-revalidated,core-pdfexpert,Landing/index locator only; locate a specific supporting page before citing a feature. +SR113,PDF Expert,PDF Expert assisted document-reading announcement,https://pdfexpert.com/blog/introducing-ai-chat,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR114,PDF Expert,Product or project documentation entry,https://readdle.com/,2026-09-07,archived-not-revalidated,core-pdfexpert,Landing/index locator only; locate a specific supporting page before citing a feature. +SR115,PDF Expert,Documentation locator: is pdf expert available on windows,https://support.readdle.com/pdfexpert/en_US/billing-subscription/is-pdf-expert-available-on-windows,2026-09-07,archived-not-revalidated,core-pdfexpert;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR116,PDF library documentation,Project source and technical documentation reference,https://raw.githubusercontent.com/podofo/podofo/1.1.0/src/podofo/main/PdfSignerCms.cpp,2026-09-09,archived-not-revalidated,design-send-for-signing,"Archived source locator; supporting passage, edition and present behavior require review." +SR117,PDF-XChange,Documentation locator: features added in v9,https://help.pdf-xchange.com/pdfxe11/features-added-in-v9.html,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR118,PDF-XChange,Documentation locator: 352,https://www.pdf-xchange.com/knowledgebase/352-,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR119,PDF-XChange,Documentation locator: 554,https://www.pdf-xchange.com/knowledgebase/554-,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR120,PDF-XChange,Documentation locator: pdf xchange ocr,https://www.pdf-xchange.com/pdf-xchange-ocr,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR121,PDF-XChange,Documentation locator: pdf xchange products comparison chart,https://www.pdf-xchange.com/pdf-xchange-products-comparison-chart,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR122,PDF-XChange,Published policy or security reference (historical),https://www.pdf-xchange.com/privacy-policy,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR123,PDF-XChange,Documentation locator: pdf xchange editor,https://www.pdf-xchange.com/product/pdf-xchange-editor,2026-09-07,archived-not-revalidated,core-pdfxchange;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR124,PDF-XChange,Release notes and change history,https://www.pdf-xchange.com/product/pdf-xchange-editor/history,2026-09-07,archived-not-revalidated,core-pdfxchange;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR125,PDF-XChange,Published policy or security reference (historical),https://www.pdf-xchange.com/support/security-bulletins.html,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR126,PDF24,Desktop Creator documentation entry,https://creator.pdf24.org/,2026-09-08,locator-reconstructed-not-revalidated,source-title-recovery,Product documentation index; exact supporting feature pages require review. +SR127,PDF24,Release notes and change history,https://creator.pdf24.org/changelog/en.html,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR128,PDF24,Documentation locator: 11,https://creator.pdf24.org/manual/11/,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR129,PDF24,Documentation locator: avoid autostart of pdf24,https://help.pdf24.org/en/forums/topic/avoid-autostart-of-pdf24/,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR130,PDF24,Documentation locator: all tools,https://tools.pdf24.org/en/all-tools,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR131,PDF24,Documentation locator: compress pdf,https://tools.pdf24.org/en/compress-pdf,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR132,PDF24,Documentation locator: creator,https://tools.pdf24.org/en/creator,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR133,PDF24,Documentation locator: ocr pdf,https://tools.pdf24.org/en/ocr-pdf,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR134,PDF24,Documentation locator: redact pdf,https://tools.pdf24.org/en/redact-pdf,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR135,PDF24,Documentation locator: en,https://www.pdf24.org/en/,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR136,PDF4QT,Project source and technical documentation reference,https://github.com/JakubMelka/PDF4QT,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR138,PDFelement,PDFelement version history,https://pdf.wondershare.com/whats-new.html,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR139,PDFgear,Product or project documentation entry,https://www.pdfgear.com/,2026-09-07,archived-not-revalidated,core-pdfgear,Landing/index locator only; locate a specific supporting page before citing a feature. +SR140,PDFgear,PDFgear version history,https://www.pdfgear.com/whats-new/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR141,PDFsam,Documentation locator: faq,https://pdfsam.org/faq/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR142,Power PDF,Power PDF licensing and edition reference (historical),https://www.tungstenautomation.com/Products/power-pdf/pricing,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR143,Power PDF,Power PDF 2025.3 release announcement,https://www.tungstenautomation.com/press-releases/2025/tungsten-automation-launches-power-pdf-2025-3,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR144,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6.8/qtquickcontrols-fluentwinui3.html,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR145,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/qcolorspace.html,2026-10-04,archived-not-revalidated,design-cmyk-lcms2-plan-2026-10-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR146,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/qimage.html,2026-10-04,archived-not-revalidated,design-cmyk-lcms2-plan-2026-10-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR147,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/qtquickcontrols-fluentwinui3.html,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR148,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/style-reference.html,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR149,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/whatsnew68.html,2026-10-04,archived-not-revalidated,design-cmyk-lcms2-plan-2026-10-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR150,Qt documentation,Release notes and change history,https://forum.qt.io/topic/156001/style-mistake-in-win11-with-qt-6-7-mingw-11-2-official-online-release,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR151,Qt documentation,Release notes and change history,https://www.qt.io/blog/qt-6.7-released,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR152,Qt documentation,Release notes and change history,https://www.qt.io/blog/qt-6.8-released,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR153,Script runtime documentation,Script runtime technical and licensing reference,https://duktape.org/download,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR154,Script runtime documentation,Script runtime technical and licensing reference,https://duktape.org/guide,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR155,Script runtime documentation,Script runtime technical and licensing reference,https://github.com/ArtifexSoftware/mujs/tags,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR156,Script runtime documentation,Script runtime repository and licensing reference,https://github.com/quickjs-ng/quickjs,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR157,Script runtime documentation,Script runtime technical and licensing reference,https://github.com/svaarala/duktape,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR158,Script runtime documentation,Script runtime technical and licensing reference,https://mujs.com/,2026-09-07,archived-not-revalidated,design-form-js,Landing/index locator only; locate a specific supporting page before citing a feature. +SR159,Script runtime documentation,License text (historical locator; review exact version before reuse),https://mujs.com/license.html,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR160,Script runtime documentation,Script runtime technical and licensing reference,https://quickjs-ng.github.io/quickjs/developer-guide/intro/,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR161,Sejda,Project source and technical documentation reference,https://github.com/torakiki/sejda,2026-09-08,archived-not-revalidated,core-sejda,"Archived source locator; supporting passage, edition and present behavior require review." +SR162,Sejda,Product or project documentation entry,https://www.sejda.com/,2026-09-08,archived-not-revalidated,core-sejda,Landing/index locator only; locate a specific supporting page before citing a feature. +SR163,Sejda,Documentation locator: workflows,https://www.sejda.com/workflows,2026-09-09,archived-not-revalidated,design-batch-presets,"Archived source locator; supporting passage, edition and present behavior require review." +SR164,Smallpdf,Documentation locator: compare,https://smallpdf.com/compare,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR165,Smallpdf,Documentation locator: desktop,https://smallpdf.com/desktop,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR166,Smallpdf,Documentation locator: windows,https://smallpdf.com/download/windows,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR167,Smallpdf,Documentation locator: edit pdf,https://smallpdf.com/edit-pdf,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR168,Smallpdf,Licensing and edition reference (historical; no current price assertion),https://smallpdf.com/pricing,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR169,Smallpdf,Published policy or security reference (historical),https://smallpdf.com/privacy,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR170,Stirling-PDF,Product or project documentation entry,https://docs.stirlingpdf.com/,2026-09-25,archived-not-revalidated,expanded-02,Landing/index locator only; locate a specific supporting page before citing a feature. +SR171,Stirling-PDF,Structured automation interface documentation,https://docs.stirlingpdf.com/Configuration/Automation/MCP%20Server/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR172,Stirling-PDF,Automation pipeline documentation,https://docs.stirlingpdf.com/Configuration/Automation/Pipeline/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR173,Stirling-PDF,Licensing and edition reference (historical; no current price assertion),https://docs.stirlingpdf.com/Modes%20and%20Licensing/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR174,Stirling-PDF,Licensing and edition reference (historical; no current price assertion),https://docs.stirlingpdf.com/Paid-Offerings/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR175,Stirling-PDF,Project source and technical documentation reference,https://github.com/Stirling-Tools/Stirling-PDF,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR176,SumatraPDF,SumatraPDF change history,https://www.sumatrapdfreader.org/docs/Changelog,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR177,SumatraPDF,Command palette and navigation prefixes,https://www.sumatrapdfreader.org/docs/Command-Palette,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR178,UPDF,Product or project documentation entry,https://ai.updf.com/,2026-09-07,archived-not-revalidated,core-updf,Landing/index locator only; locate a specific supporting page before citing a feature. +SR179,UPDF,Documentation locator: newgoodslist,https://checkout.updf.com/v1/order/newGoodsList?goodsType=1&channel=SEO-EN-AI¤cy=USD,2026-09-07,archived-not-revalidated,core-updf,"Archived source locator; supporting passage, edition and present behavior require review." +SR180,UPDF,Product or project documentation entry,https://sign.updf.com/,2026-09-07,archived-not-revalidated,core-updf,Landing/index locator only; locate a specific supporting page before citing a feature. +SR181,UPDF,Licensing and edition reference (historical; no current price assertion),https://updf.com/pricing/,2026-09-07,archived-not-revalidated,core-updf,"Archived source locator; supporting passage, edition and present behavior require review." +SR182,UPDF,Documentation locator: tech spec,https://updf.com/tech-spec/,2026-09-07,archived-not-revalidated,core-updf,"Archived source locator; supporting passage, edition and present behavior require review." +SR183,UPDF,Release notes and change history,https://updf.com/whats-new/,2026-09-07,archived-not-revalidated,core-updf;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR184,UPDF,Documentation locator: write a review for updf,https://updf.com/write-a-review-for-updf/,2026-09-07,archived-not-revalidated,core-updf,"Archived source locator; supporting passage, edition and present behavior require review." diff --git a/docs/research/pdf-workflows/workflow-reference.md b/docs/research/pdf-workflows/workflow-reference.md new file mode 100644 index 0000000000..c0a419711a --- /dev/null +++ b/docs/research/pdf-workflows/workflow-reference.md @@ -0,0 +1,430 @@ +# Workflow research and verification reference + +This reference connects archived workflow research with PdfCraft's existing feature catalogue. It is not a roadmap, a set of implementation assignments, or an approval to introduce features, storage formats, dependencies or architecture. Planning remains in the repository's existing `ROADMAP.md` and `parity/` records. +The catalogue and roadmap snapshot is [`924c4da4`](https://github.com/storytold/pdfcraft/tree/924c4da4ac2879ac9107c6c82e20ac6be5570f0a), dated 2026-10-16. Engine and automation names were inspected separately at [`3f044b62`](https://github.com/storytold/pdfcraft/tree/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6), dated 2026-10-13. Catalogue status means the status recorded at that revision; it is not an independent verification verdict. No implementation tests, benchmarks, competitor sessions or conformance checks were executed for this reference. +The [competitive research digest](competitive-research.md) provides the archived context. The [source register](source-register.csv) records source identifiers, URLs, archive dates and verification limits. Links below are locators for the retained research, not fresh confirmations of vendor behavior. The [feature mapping](feature-mapping.csv) contains the corresponding catalogue records. B01–B20 are reference section identifiers, not a second task or priority system. +Evaluation dimensions name what an observation describes; they do not prescribe targets or new acceptance gates. Availability, structural validity, visual fidelity, interoperability and human usability are distinct evidence. Negative or incomplete observations remain distinguishable from successful checks. + +## B01 — Rendering and output fidelity + +**Recorded baseline.** The checklist records six mapped rendering features as partial. Its knockout entry is marked shipped, but cites signing tests and signed-save notes; the transparency entry describes knockout rendering as incomplete. Those are inconsistent catalogue references, not evidence from a fresh rendering test. The current progress log and gap 12 document existing `cargo xtask render-oracle` and `cargo xtask text-oracle` tools, including pixel disagreements and reading-order measurements. These tools report differences; their output is not a fidelity certification. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `view.render-pages` | partial | M2 | +| `view.render-shadings-patterns` | partial | M2 | +| `view.render-transparency` | partial | M2 | +| `view.render-knockout` | shipped | M11 | +| `view.render-type3` | partial | M2 | +| `view.render-cid-fonts` | partial | M2 | +| `view.render-image-codecs` | partial | M2 | + +**Archived research question.** The archived viewing and conversion studies distinguish whether a file opens, whether its text can be extracted, and whether the visible page matches a reference. These observations concern different kinds of fidelity. + +**Evaluation dimensions.** Pixel differences and their locations; glyph identity and reading order; clipping and transparency; independent-reader outcomes; oracle version and tolerance; unsupported or incomplete analysis. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/render/src/raster.rs::renders_a_blue_rectangle`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/render/src/raster.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B02 — Existing-text editing + +**Recorded baseline.** Text editing and RTL/CJK editing are partial. The mapped rotated-text and tag-preservation entries remain planned. Inspected source exposes text_edit, text_edit_layout, text_fonts and text_font_import; this does not establish general support for every font or script. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `edit.text-edit` | partial | M7 | +| `edit.rtl-cjk-editing` | partial | M7 | +| `edit.rotated-text-editing` | planned | M7 | +| `edit.keep-tags-on-edit` | planned | M7 | + +**Archived research question.** The archived editing studies ask whether correcting text preserves its font, baseline, writing order, nearby content and document structure. A visually acceptable edit and a semantically faithful edit are separate observations. + +**Evaluation dimensions.** Extracted Unicode and logical order; shaping and missing-glyph behavior; retained font/style; neighbouring content; selection/caret behavior; saved/reopened layout; unsupported transformations. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/edit/src/tests.rs); [`crates/edit/src/bidi.rs::hebrew_sentences_numbers_latin_and_brackets_round_trip`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/edit/src/bidi.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B03 — Document comparison + +**Recorded baseline.** Text comparison, visual comparison, PDF reports and side-by-side views are shipped in the current checklist. The side-by-side notes describe an old-tab requirement, a minimum window width of 1000 px and offset-based synchronization. They identify content-aligned scrolling and retaining comparison after the old tab closes as limitations. Filters, selected ranges and scanned comparison remain planned. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `misc.compare-text` | shipped | M12 | +| `misc.compare-visual` | shipped | M12 | +| `misc.compare-report` | shipped | M12 | +| `misc.compare-side-by-side` | shipped | M12 | +| `misc.compare-filters` | planned | M12 | +| `misc.compare-page-ranges` | planned | M12 | +| `misc.compare-scanned` | planned | M12 | + +**Archived research question.** Archived comparison studies distinguish a textual change list, raster differences, overlays and synchronized old/new context. The research question is how a reviewer associates a reported difference with both document locations. + +**Evaluation dimensions.** Difference type and location; page alignment with insertions or unequal sizes; zoom/scroll correspondence; navigation counts; report destinations; identical-input outcomes; input immutability. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/compare/src/lib.rs::insertions_deletions_and_replacements`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/compare/src/lib.rs); [`crates/engine/src/tests.rs::comparing_two_versions_of_a_document`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/tests.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR018: Bluebeam Revu — Documentation locator: compare documents vs overlay pages](https://support.bluebeam.com/revu/features/compare-documents-vs-overlay-pages.html); [SR030: Bluebeam Revu — Documentation locator: compare documents](https://support.bluebeam.com/user-manual/menus/document/compare-documents.html); [SR002: ABBYY FineReader — User guide index: verification, comparison and automation](https://help.abbyy.com/en-us/finereader/16/). Verification state and date are in the source register. + +## B04 — Recognition proofing and correction + +**Recorded baseline.** Recognition of pages, ranges and multiple files is shipped; editable-text output is partial; correction of low-confidence suspects is planned. Inspected source includes ocr_recognize, ocr_recognize_files and ocr_status. These operations alone do not establish a proofing workflow. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `ocr.correct-recognized-text` | planned | M10 | +| `ocr.editable-text-output` | partial | M10 | +| `ocr.recognize-text` | shipped | M10 | +| `ocr.recognize-range` | shipped | M10 | +| `ocr.recognize-multiple` | shipped | M10 | + +**Archived research question.** The archived recognition study separates image/text/zoom synchronization, navigation among uncertain material, correction, skipping and human-reviewed state. Confidence from an engine and a human review decision are distinct evidence. + +**Evaluation dimensions.** Word-to-image correspondence; confidence provenance or absence; correction in extracted text after save/reopen; retained scan appearance; skipped versus reviewed state; stale recognition results; keyboard reachability. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/engine/tests/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/tests/ocr_edit.rs); [`crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/tests.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR002: ABBYY FineReader — User guide index: verification, comparison and automation](https://help.abbyy.com/en-us/finereader/16/); [SR031: Bluebeam Revu — Documentation locator: ocr](https://support.bluebeam.com/user-manual/menus/document/ocr.html). Verification state and date are in the source register. + +## B05 — Recognition languages and scan preparation + +**Recorded baseline.** Language support is partial; orientation detection, deskew and pluggable engines are planned. Camera-image cleanup provides partial despeckle, background-removal and perspective capabilities. The notes do not establish equivalent preprocessing of arbitrary scanned pages. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `ocr.ocr-languages` | partial | M10 | +| `ocr.rotation-detection` | planned | M10 | +| `ocr.deskew` | planned | M10 | +| `ocr.ocr-engines` | planned | M10 | +| `ocr.despeckle` | partial | M10 | +| `ocr.background-removal` | partial | M10 | +| `ocr.camera-perspective` | partial | M10 | + +**Archived research question.** The archived scan studies distinguish selected language from the language actually recognized and distinguish camera perspective correction from text orientation, deskew and image cleanup. + +**Evaluation dimensions.** Actual backend/language; model availability and provenance; rotation/skew geometry; text accuracy by script; annotation/search coordinate agreement; pages with existing text; duplicate-layer behavior; explicit unsupported options. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/ocr.rs::recognize_text_dialog_adds_searchable_text`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/ocr.rs); [`crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/camera_clean.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR002: ABBYY FineReader — User guide index: verification, comparison and automation](https://help.abbyy.com/en-us/finereader/16/); [SR120: PDF-XChange — Documentation locator: pdf xchange ocr](https://www.pdf-xchange.com/pdf-xchange-ocr); [SR133: PDF24 — Documentation locator: ocr pdf](https://tools.pdf24.org/en/ocr-pdf). Verification state and date are in the source register. + +## B06 — Reusable batch processing + +**Recorded baseline.** Action creation and execution are shipped; management/exchange and prompt-user steps remain planned. The inspected 2026-10-13 actions.rs has 12 step kinds, and action_list/action_run are present. This is an older source observation, not a new inspection of the current runner. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `misc.action-wizard-run` | shipped | M13 | +| `misc.action-wizard-create` | shipped | M13 | +| `misc.action-wizard-manage` | planned | M13 | +| `misc.action-prompt-steps` | planned | M13 | + +**Archived research question.** Archived workflow studies describe named sequences, ordered steps, portable definitions and per-file outcomes. They distinguish a saved process definition from the outcomes of a particular execution. + +**Evaluation dimensions.** Definition/version round-trip; step order and arguments; input/output identity; occupied output names; individual failure reporting; partial batch outcomes; cancellation; path confinement; secret handling in reports. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/actions.rs::desktop_actions_preserve_duplicate_names_and_results_from_earlier_runs`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/actions.rs); [`crates/automation/tests/automation.rs::actions_through_tools`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR056: Foxit PDF Editor — Documentation locator: add custom commands to custom actions](https://www.foxit.com/resource-hub/user-guide/foxit-pdf-editor-for-windows/add-custom-commands-to-custom-actions/); [SR172: Stirling-PDF — Automation pipeline documentation](https://docs.stirlingpdf.com/Configuration/Automation/Pipeline/). Verification state and date are in the source register. + +## B07 — Document structure and accessibility authoring + +**Recorded baseline.** Text-only autotag, Tags/property editing, sibling reading-order edits and artifact marking are partial. The current artifact entry covers reachable indirect leaf tags owning direct MCIDs through the panel and accessibility_mark_tag_artifact. Arbitrary painted selection, direct dictionaries and whole tagged subtrees remain outside that entry. accessibility_tags is described as paginated and filterable. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `a11y.autotag` | partial | M12 | +| `a11y.tags-panel` | partial | M12 | +| `a11y.tag-properties` | partial | M12 | +| `a11y.reading-order-tool` | partial | M12 | +| `a11y.order-panel` | partial | M12 | +| `a11y.table-editor` | planned | M12 | +| `a11y.create-tag-from-selection` | planned | M12 | +| `a11y.artifact-marking` | partial | M12 | + +**Archived research question.** Archived accessibility studies distinguish automatic classification, actual structure-tree changes, reading order and human interpretation. A visual panel, a structurally valid parent tree and an accessible reading experience are different observations. + +**Evaluation dimensions.** MCID/parent-tree consistency; content ownership; tag and reading order; alternate text; direct/indirect dictionary coverage; malformed or ambiguous structure; appearance after artifact marking; saved/reopened structure; manual-check findings. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/automation/tests/automation.rs::autotag_preview_proposes_text_without_changing_the_pdf`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs); [`crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/accessibility.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR108: PDF Association — Accessible PDF standards and reference material](https://pdfa.org/iso-14289-2-pdf-ua-2-the-gold-standard-for-accessibility-in-pdf-2-0-has-arrived/); [SR110: PDF Association — Accessible PDF standards and reference material](https://pdfa.org/wtpdf/); [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf). Verification state and date are in the source register. + +## B08 — Preflight and archival validation + +**Recorded baseline.** PDF/A verification and conversion are partial. Preflight analysis, result presentation and reports remain planned. Inspected source exposes pdfa_verify, pdfa_convert and print_readiness; their availability is not proof of full conformance validation. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `optimize.pdfa-validate` | partial | M11 | +| `optimize.pdfa-convert` | partial | M11 | +| `optimize.preflight` | planned | M11 | +| `optimize.preflight-results` | planned | M11 | +| `optimize.preflight-reports` | planned | M11 | + +**Archived research question.** The archived standards studies distinguish declared conformance, implemented structural checks, conversion changes and independent validation. A repairable finding is different from a condition the software cannot evaluate. + +**Evaluation dimensions.** Profile and rule coverage; page/object identification; unsupported checks; fonts and output intents; changes made by conversion; remaining findings after save/reopen; agreement and disagreement with an identified independent validator. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/preflight/src/tests.rs); [`crates/automation/tests/automation.rs::pdfa_through_tools`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR076: LibreOffice Draw — PDF export and security help index](https://help.libreoffice.org/latest/en-US/text/shared/01/ref_pdf_export.html); [SR105: PDF Association — Product or project documentation entry](https://pdfa.org). Verification state and date are in the source register. + +## B09 — Redaction and assurance evidence + +**Recorded baseline.** The mapped excision and verification features are shipped. Redaction properties are also shipped, including overlay/mark styling, persistent defaults, session-only changes and cancel behavior. Pattern locales, partial words and folder redaction remain planned; code sets are partial. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `protect.redaction-verification` | shipped | M8 | +| `protect.redact-glyph-removal` | shipped | M8 | +| `protect.redact-image-pixels` | shipped | M8 | +| `protect.redact-vectors` | shipped | M8 | +| `protect.redact-annots-fields` | shipped | M8 | +| `protect.redact-struct-tree` | shipped | M8 | +| `protect.redact-pattern-locales` | planned | M8 | +| `protect.redact-partial-words` | planned | M8 | +| `protect.redact-folder` | planned | M13 | +| `protect.redaction-properties` | shipped | M8 | +| `protect.redaction-codes` | partial | M8 | + +**Archived research question.** Archived redaction studies distinguish a visible cover from removal of underlying content and distinguish a scoped verification result from an unlimited security guarantee. A report containing removed text can itself disclose sensitive content. + +**Evaluation dimensions.** Search/extraction after save/reopen; image/vector/annotation/structure paths covered; hidden-information treatment; input/output hashes; unsupported constructs; false success on a cover-only control; refusal/cancellation behavior; sensitive data in exported records. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/redact/src/tests.rs::unreadable_content_fails_closed`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/redact/src/tests.rs); [`crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/ui_group/redact.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR034: Bluebeam Revu — Documentation locator: redaction](https://support.bluebeam.com/user-manual/menus/edit/redaction.html); [SR097: Nitro PDF Pro — Documentation locator: nitros complete guide to secure automated pdf redaction](https://www.gonitro.com/resources/nitros-complete-guide-to-secure-automated-pdf-redaction); [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf). Verification state and date are in the source register. + +## B10 — Digital signatures and validation evidence + +**Recorded baseline.** The mapped B-B, PKCS #12, native Windows/macOS identity and certification capabilities are shipped. Timestamp preferences, PKCS #11 and FieldMDP are planned; B-T/LTV/B-LTA and online revocation support are partial. Inspected source exposes sign_document, sign_list and sign_trust. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `sign.cades-pades-bb` | shipped | M9 | +| `sign.pkcs12` | shipped | M9 | +| `sign.windows-cert-store` | shipped | M9 | +| `sign.macos-keychain` | shipped | M9 | +| `sign.certify` | shipped | M9 | +| `sign.timestamp-servers` | planned | M9 | +| `sign.pkcs11` | planned | M9 | +| `sign.field-mdp` | planned | M9 | +| `sign.pades-bt` | partial | M9 | +| `sign.ltv` | partial | M9 | +| `sign.pades-blta` | partial | M9 | +| `sign.ocsp-crl` | partial | M9 | + +**Archived research question.** Archived signing studies distinguish document integrity, signer trust, permitted changes, timestamp evidence and long-term validation material. A local signing workflow is different from a hosted signature-request service. + +**Evaluation dimensions.** Covered revision; permitted/disallowed changes; intact versus trusted status; timestamp presence and trust; revocation-evidence freshness; requested versus attained level; network-consent state; private-key custody; reopened independent validation. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/sign/tests/crypto.rs::signatures_round_trip_for_every_key_type`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/sign/tests/crypto.rs); [`crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/sign/tests/pdf.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR028: Bluebeam Revu — Documentation locator: sign seal](https://support.bluebeam.com/user-manual/menus/batch/sign-seal.html); [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B11 — Forms, events and scripting + +**Recorded baseline.** Custom scripts, event order and calculation order are shipped. Form submission and the mapped general JavaScript action are planned; push-button actions are partial. Inspected source includes js_run, js_document_scripts, form_set_script, form_actions, form_set_actions and js_enabled. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `form.custom-scripts` | shipped | M6 | +| `form.event-order` | shipped | M6 | +| `form.calculation-order` | shipped | M6 | +| `form.submit-form` | planned | M6 | +| `edit.action-javascript` | planned | M6 | +| `form.push-buttons` | partial | M6 | + +**Archived research question.** Archived form studies separate compatibility with installed forms from authoring tools and separate field-event order from document-level actions. Scripting availability does not imply filesystem or network privileges. + +**Evaluation dimensions.** Trigger timing; validation/calculation/format order; committed values after save/reopen; enabled/disabled script state; resource bounds; malformed scripts; unwanted external effects; cancellation and refusal outcomes. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/tests.rs); [`crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/forms/src/tests.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR012: Adobe Acrobat — Acrobat JavaScript API reference](https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsapiref/index.html); [SR013: Adobe Acrobat — Acrobat JavaScript developer guide](https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsdevguide/index.html); [SR043: Firefox / pdf.js — Form scripting API implementation reference](https://github.com/mozilla/pdf.js/blob/master/src/scripting_api/aform.js). Verification state and date are in the source register. + +## B12 — Measurement and scale interpretation + +**Recorded baseline.** Distance and scale are partial; perimeter, area, snapping, information display and CSV export are shipped. Current mapped measurement entries are assigned to M12. Inspected source includes the measure_* tool family. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `comment.measure-distance` | partial | M12 | +| `comment.measure-scale` | partial | M12 | +| `comment.measure-perimeter` | shipped | M12 | +| `comment.measure-area` | shipped | M12 | +| `comment.measure-snap` | shipped | M12 | +| `comment.measure-info-panel` | shipped | M12 | +| `comment.measure-export` | shipped | M12 | + +**Archived research question.** Archived technical-review studies distinguish measurement geometry from unit formatting, calibration and interchange. Reading a saved measurement is different from assuming every imported scale is supported. + +**Evaluation dimensions.** Known dimensions and numeric tolerance; selected viewport and scale; compound/fractional formats; rotated/cropped pages; imported unsupported formats; self-intersecting boundaries; saved/reopened values; CSV escaping and spreadsheet safety. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs); [`crates/measure/src/tests.rs::viewport_precedence_first_point_user_unit_and_roundtrip`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/measure/src/tests.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR107: PDF Association — Foxit PDF Editor 2026.2 release announcement](https://pdfa.org/foxit-pdf-editor-20262-release-notes/). Verification state and date are in the source register. + +## B13 — Page assembly and document composition + +**Recorded baseline.** Combine, selected page ranges, sorting/grid previews, every-N/bookmark splitting and field-name conflict handling are shipped. Structure-tree merging, multiple-file split and the mapped naming refinements remain planned. Existing combine and page tools were present in the inspected source. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `organize.combine-files` | shipped | M4 | +| `organize.combine-choose-pages` | shipped | M4 | +| `organize.combine-sort` | shipped | M4 | +| `organize.combine-thumbnails` | shipped | M4 | +| `organize.field-name-conflicts` | shipped | M4 | +| `organize.split-every-n` | shipped | M4 | +| `organize.split-by-bookmarks` | shipped | M4 | +| `organize.struct-tree-merge` | planned | M12 | +| `organize.split-multiple-files` | planned | M13 | +| `organize.split-naming` | planned | M4 | + +**Archived research question.** Archived composition studies examine how users review source pages, order and destination before a transfer. Visible selection, the captured source revision and the resulting document are separate pieces of evidence. + +**Evaluation dimensions.** Page order; source/target identity; unsaved-source handling; links, fields and bookmarks; size mismatch; field-name conflicts; structure preservation; source immutability; undo; saved/reopened destinations. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/organize/src/tests.rs); [`crates/ui-egui/tests/editing.rs::combine_files_takes_chosen_pages_in_the_order_listed`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/editing.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B14 — Creation and conversion fidelity + +**Recorded baseline.** Office import and spreadsheet export are planned; DOCX and presentation export are partial. DOCX notes record package validation and DrawingML picture anchors on positioned pages, with that positioned output still unverified in Word. Package validity does not establish visual fidelity. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `create.from-office` | planned | M10 | +| `create.export-docx` | partial | M10 | +| `create.export-xlsx` | planned | M10 | +| `create.export-pptx` | partial | M10 | + +**Archived research question.** Archived conversion studies distinguish reflowed editable output, positioned output, raster output and native PDF edits. Their suitability depends on what information the user needs to retain. + +**Evaluation dimensions.** Text and table semantics; headers/footers; page dimensions; images and vector rules; paint order; exact font matching; package validity; reopened rendering; independent application/version; explicit unsupported input and output. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/export/src/lib.rs); [`crates/export/src/pptx.rs::pptx_is_a_presentationml_package`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/export/src/pptx.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR076: LibreOffice Draw — PDF export and security help index](https://help.libreoffice.org/latest/en-US/text/shared/01/ref_pdf_export.html); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B15 — Document performance and resource use + +**Recorded baseline.** Smooth 500-page scrolling is shipped. Performance budgets and lazy loading of very large files are planned. Existing notes describe whole-file input and remaining save-memory peaks; they do not establish bounded performance for every document. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `view.large-document-performance` | shipped | M3 | +| `misc.performance-budgets` | planned | M14 | +| `core.lazy-loading` | planned | M1 | + +**Archived research question.** Archived performance studies distinguish open time, first useful page, interaction latency and job completion. Input byte size and page count alone do not describe rendering complexity. + +**Evaluation dimensions.** Release build and hardware; corpus generation; fresh-process runs and variance; peak resident memory; open/edit/save timings; UI-thread stalls; cache state; cancellation latency; bounds on hostile dimensions; output validity after reopen. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/perf.rs::scrolling_a_500_page_document_stays_within_the_frame_budget`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/perf.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR099: Okular — Project source and technical documentation reference](https://github.com/KDE/okular). Verification state and date are in the source register. + +## B16 — Search across document collections + +**Recorded baseline.** Advanced current-document search is partial; folder, Boolean and indexed search are planned. Inspected source includes text_find and extraction. Mapped search entries belong to M12. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `view.advanced-search` | partial | M12 | +| `view.search-folder` | planned | M12 | +| `view.search-boolean` | planned | M12 | +| `view.search-index` | planned | M12 | + +**Archived research question.** Archived search studies distinguish current-file matching, collection traversal, persistent indexing and navigation back to a location. A result count is incomplete without the set of files actually searched. + +**Evaluation dimensions.** Query semantics; searched/excluded/failed files; path aliases and cycles; root confinement; result and cache bounds; stale locations after edits; location after reopen; cancellation; truncation and incomplete-result reporting. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/viewer.rs::advanced_search_lists_results_with_context`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/viewer.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR177: SumatraPDF — Command palette and navigation prefixes](https://www.sumatrapdfreader.org/docs/Command-Palette); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B17 — Keyboard and assistive-technology interaction + +**Recorded baseline.** App screen-reader support is partial and keyboard-only operation is planned. Reading colors are shipped; current notes cover annotation appearances and highlight legibility. Existing labels and display-color behavior do not establish completion of every task with assistive technology. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `a11y.app-screen-reader` | partial | M3 | +| `a11y.keyboard-only` | planned | M14 | +| `a11y.replace-page-colors` | shipped | M12 | + +**Archived research question.** Archived interaction studies distinguish the accessibility tree, keyboard focus and what a platform screen reader announces. Successful automated tree inspection and a successful human task are different evidence. + +**Evaluation dimensions.** Names, roles, values and states; complete focus path; focus restoration; error/completion announcements; disabled-state explanations; selection after page changes; non-color information; platform/screen-reader version; manual versus automated findings. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/control.rs); [`crates/automation/tests/automation.rs::reading_colors_through_tools`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR099: Okular — Project source and technical documentation reference](https://github.com/KDE/okular); [SR104: Okular — Project source and technical documentation reference](https://raw.githubusercontent.com/KDE/okular/master/doc/index.docbook); [SR108: PDF Association — Accessible PDF standards and reference material](https://pdfa.org/iso-14289-2-pdf-ua-2-the-gold-standard-for-accessibility-in-pdf-2-0-has-arrived/). Verification state and date are in the source register. + +## B18 — Preferences and capability disclosure + +**Recorded baseline.** The preferences dialog is partial; settings search, admin defaults and RTL UI are planned. Existing categories include language, document/view, identity, Fill & Sign and JavaScript. Current notes describe XDG migration that preserves configured preferences; they do not establish a complete signature-preferences category. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `misc.preferences-dialog` | partial | M14 | +| `misc.prefs-search` | planned | M14 | +| `misc.admin-preferences` | planned | M14 | +| `misc.rtl-ui` | planned | M14 | + +**Archived research question.** Archived shell and deployment studies distinguish application preferences, document overrides, session state and managed policy. A visible option is different from an available runtime capability. + +**Evaluation dimensions.** Scope and precedence; default versus override; persistence across restart/reopen; malformed stored values; migration preservation; policy-locked state; cancellation of drafts; consistency among supported entry points. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/javascript.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR080: Master PDF Editor — Workspace navigation and object inspection](https://code-industry.net/masterpdfeditor-help/workspace-master-pdf-editor/); [SR177: SumatraPDF — Command palette and navigation prefixes](https://www.sumatrapdfreader.org/docs/Command-Palette). Verification state and date are in the source register. + +## B19 — Print preparation and color behavior + +**Recorded baseline.** Printing, n-up/booklet and previews exist. Saved print presets and the mapped output-preview/color-production features are planned. Inspected source includes print_readiness; that inspection path is not a full production Preflight. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `organize.print-presets` | planned | M10 | +| `print.output-preview` | planned | M11 | +| `print.color-warnings` | planned | M11 | +| `print.convert-colors` | planned | M11 | +| `print.color-management` | planned | M11 | + +**Archived research question.** Archived print studies distinguish display-only reading colors, print-preview appearance, color conversion and the output intent. An ICC profile or a printer selection has meaning only with an identified processing path. + +**Evaluation dimensions.** Page order and dimensions; persistent print options; preview/output correspondence; fonts and color-space findings; profile provenance and rendering intent; unsupported separations; device/platform differences; reopened print-ready output. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites the per-feature evidence recorded in its entries. These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR076: LibreOffice Draw — PDF export and security help index](https://help.libreoffice.org/latest/en-US/text/shared/01/ref_pdf_export.html); [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf). Verification state and date are in the source register. + +## B20 — Document identity, history and failure outcomes + +**Recorded baseline.** The mapped undo, atomic batch-edit, autosave, crash-recovery and unsaved-change capabilities are shipped. Current recovery notes cover instance locks that prevent another running instance from offering/discarding live autosaves. Browser recovery and multiple windows remain planned. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `core.undo-redo` | shipped | M4 | +| `core.undo-after-save` | shipped | M4 | +| `core.atomic-batch-edits` | shipped | M4 | +| `core.autosave` | shipped | M4 | +| `core.crash-recovery` | shipped | M4 | +| `core.unsaved-changes-prompt` | shipped | M4 | +| `core.web-recovery` | planned | M4 | +| `view.multiple-windows` | planned | M14 | + +**Archived research question.** Archived document-ownership studies distinguish the active tab from the document captured by a long-running operation and distinguish a staged candidate from an accepted save. Those studies are not evidence of a defect in current PdfCraft. + +**Evaluation dimensions.** Captured document/revision; destination identity; close-during-save outcome; external file replacement; write failure; coherent undo/redo; unrelated-document preservation; recovery binding; saved/reopened accepted state; cancellation and reported non-interruptible stages. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/engine/src/tests.rs::undo_and_redo_restore_exact_states`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/tests.rs); [`crates/ui-egui/tests/recovery.rs::saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/recovery.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR099: Okular — Project source and technical documentation reference](https://github.com/KDE/okular). Verification state and date are in the source register. diff --git a/docs/roadmap.md b/docs/roadmap.md index a7f4a86b7e..023688ddc4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -147,6 +147,8 @@ repaint at the existing 60-second autosave deadline, even with no mouse or keybo Clean and already-snapshotted documents do not schedule recovery wakeups. Snapshot contents, encryption and storage-write retry behavior are unchanged; this does not resolve #1803. +The [PDF workflow research reference](research/pdf-workflows/README.md) collects archived findings and a dated feature mapping. Planning remains in this roadmap and the parity checklist. + ## Alpha gate The alpha gate: Acrobat Pro's everyday core workflows must each work end to end on the diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3523db9eed..a0ea857f1a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -9,6 +9,8 @@ interface is copied. Gaps ranked in [gaps.md](gaps.md). **UI/UX fidelity ≈ 55% ready** (estimated), **30–60 h** additional to the features total. +The [PDF workflow research reference](research/pdf-workflows/README.md) collects comparative source locators, interaction evidence and evaluation criteria. It does not change this checklist. + ## Measured - **184 registered commands** (`crates/engine/src/commands.rs`), every one in the menus, the From 431a4e68aecde25a67af1c9aa66c3759c5067e18 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:10:43 +0200 Subject: [PATCH 15/43] Test that moving an image keeps its marked-content sequence intact (#4248) * Test that moving an image keeps it inside its marked-content sequence translate_images changes the image's own cm and leaves its Do where it was, so a moved image stays in the BDC ... EMC sequence that tags it, whether that is a tagged-PDF element or an optional-content layer. Nothing pinned this: the image-move tests covered clips, transforms and repeated moves but not marked content. The test moves an image inside /P <> BDC and inside /OC /L0 BDC and checks that both sequences remain, the Do is still inside its own, and the other sequence gained no image. * Test image moves on a real optional-content layer --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/edit/src/tests.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index cbea5c21d2..ecbb8b3645 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3514,7 +3514,10 @@ fn page_content_bytes(doc: &Document, page: usize) -> Vec { } } -/// A page drawing image XObject /Im0 at 100,100 size 200 × 100 (pixels 4 × 2). +/// A page drawing image XObject /Im0 at 100,100 size 200 × 100 (pixels 4 × 2), on a real optional +/// content layer: the catalog's `/OCProperties` declares object 7 as the only OCG and the page +/// resources' `/Properties` maps `/L0` to it, so `/OC /L0 BDC` marks a valid optional-content +/// member rather than a dangling name. fn image_page() -> Document { image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET") } @@ -3534,12 +3537,13 @@ fn remove_crop(doc: &mut Document, page: usize, index: usize) -> Result<(), Edit fn image_page_with(content: &str) -> Document { let objs: Vec = vec![ - "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [7 0 R] /D << /Order [7 0 R] >> >> >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /Type /ExtGState /ca 0.5 /CA 0.5 >> /GSalpha << /Type /ExtGState /ca 0.6 >> /GSblend << /Type /ExtGState /BM /Multiply >> /GS_translucent << /Type /ExtGState /ca 0.5 >> >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /Type /ExtGState /ca 0.5 /CA 0.5 >> /GSalpha << /Type /ExtGState /ca 0.6 >> /GSblend << /Type /ExtGState /BM /Multiply >> /GS_translucent << /Type /ExtGState /ca 0.5 >> >> /Properties << /L0 7 0 R >> >> >>".into(), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(), + "<< /Type /OCG /Name (Layer 0) >>".into(), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); @@ -3753,6 +3757,30 @@ fn repeated_group_moves_of_a_cropped_image_change_one_clip_transform() { assert_eq!(caption, ["Caption"]); } +#[test] +fn moving_an_image_inside_marked_content_leaves_every_sequence_intact() { + // `translate_images` changes the image's own `cm`; its `Do` stays where it was, so a moved + // image stays in the sequence that tags it (an image on a layer sits in `/OC … BDC`). + for content in [ + "/P <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q EMC /P <> BDC BT /F1 12 Tf 72 700 Td (Text) Tj ET EMC", + "/OC /L0 BDC q 100 0 0 100 50 50 cm /Im0 Do Q EMC /P <> BDC BT /F1 12 Tf 72 700 Td (Text) Tj ET EMC", + ] { + let mut doc = image_page_with(content); + let before = images::page_images(&doc, 0).unwrap()[0].rect; + images::translate_images(&mut doc, 0, &[0], [10.0, 0.0]).unwrap(); + let after = images::page_images(&doc, 0).unwrap()[0].rect; + assert!(close(after, [before[0] + 10.0, before[1], before[2] + 10.0, before[3]]), "{content}: {after:?}"); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let count = |name: &str| ops.iter().filter(|op| op.is(name)).count(); + assert_eq!((count("BDC"), count("EMC")), (2, 2), "{content}: both sequences remain"); + let first = ops.iter().position(|op| op.is("BDC")).unwrap(); + let end = ops.iter().position(|op| op.is("EMC")).unwrap(); + let image = ops.iter().position(|op| op.is("Do")).unwrap(); + assert!(first < image && image < end, "{content}: the image is still inside its own sequence"); + assert!(!ops[end..].iter().any(|op| op.is("Do")), "{content}: the other sequence gained no image"); + } +} + #[test] fn page_images_move_turn_replace_and_delete() { let mut doc = image_page(); From 7fd6860f4ad81d9cdbfc2d9d80488c837f26f3b0 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:10:49 +0200 Subject: [PATCH 16/43] M13 batch: pin the command-line exit codes before any change (#6296) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- apps/pdfcraft-cli/tests/it/batch_exit.rs | 111 +++++++++++++++++++++++ apps/pdfcraft-cli/tests/it/main.rs | 1 + 2 files changed, 112 insertions(+) create mode 100644 apps/pdfcraft-cli/tests/it/batch_exit.rs diff --git a/apps/pdfcraft-cli/tests/it/batch_exit.rs b/apps/pdfcraft-cli/tests/it/batch_exit.rs new file mode 100644 index 0000000000..6dfb9f61e9 --- /dev/null +++ b/apps/pdfcraft-cli/tests/it/batch_exit.rs @@ -0,0 +1,111 @@ +//! The exit codes the CLI has today: `ExitCode::SUCCESS` (0) or `ExitCode::FAILURE` (1). +//! +//! Pinned here so that the exit-code contract is a visible, reviewed change when it comes: a +//! failed file, an outside-root path and a usage error all exit 1 today. + +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// A fresh folder for one test, so the tests can run in parallel and leave nothing behind. +fn workdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-exit-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// `batch` with `args`, run from the working directory `dir`. +fn batch(dir: &Path, args: &[&str]) -> Output { + Command::new(BIN).current_dir(dir).arg("batch").args(args).output().unwrap() +} + +/// `run --script --report` from the working directory `dir`: one JSON document for the run. +fn run_report(dir: &Path, script: &Path) -> Output { + Command::new(BIN).current_dir(dir).args(["run", "--script"]).arg(script).arg("--report").output().unwrap() +} + +/// The one JSON document a command prints on stdout. +fn reported(output: &Output) -> Value { + serde_json::from_slice(&output.stdout).unwrap() +} + +#[test] +fn batch_exits_0_when_every_file_succeeds() { + let dir = workdir("batch-ok"); + std::fs::write(dir.join("page.txt"), "Exit code page").unwrap(); + let output = batch(&dir, &["page.txt", "--steps", "convert", "--out-dir", "out"]); + assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr)); + let report = reported(&output); + assert_eq!(report["completed"], 1, "{report}"); + assert_eq!(report["failed"], 0, "{report}"); + assert!(dir.join("out/page.pdf").exists(), "the converted page is written"); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn batch_exits_1_when_one_file_fails() { + let dir = workdir("batch-fail"); + std::fs::write(dir.join("bad.pdf"), "broken").unwrap(); + let output = batch(&dir, &["bad.pdf", "--steps", "convert", "--out-dir", "out"]); + assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr)); + let report = reported(&output); + assert_eq!(report["failed"], 1, "{report}"); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn batch_usage_error_exits_1_and_creates_nothing() { + let dir = workdir("batch-usage"); + std::fs::write(dir.join("page.txt"), "Exit code page").unwrap(); + // Baseline: a usage error exits 1 today. A later change to the exit-code contract will + // update this assertion on purpose. + let output = batch(&dir, &["page.txt", "--out-dir", "out"]); + assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(output.stdout.is_empty(), "a usage error prints to stderr, not a report on stdout"); + assert!(!output.stderr.is_empty(), "a usage error says what is wrong"); + assert!(!dir.join("out").exists(), "a refused batch creates no output"); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn batch_outside_root_exits_1_and_creates_nothing() { + let dir = workdir("batch-outside-root"); + let output = Command::new(BIN) + .current_dir(&dir) + .args(["batch", "../outside.pdf", "--steps", "convert", "--out-dir", "out", "--root"]) + .arg(&dir) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("is outside the allowed directory"), "the input is refused by the root: {stderr}"); + assert!(output.stdout.is_empty(), "a refused batch prints no report"); + assert!(!dir.join("out").exists(), "a refused batch creates no output"); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn run_script_report_exits_0_when_ok_and_1_when_a_step_fails() { + let dir = workdir("run-report"); + let script = dir.join("steps.json"); + let blank = serde_json::json!({ "tool": "doc_create", "args": { "from": "blank", "width": 100, "height": 100 } }); + let steps = serde_json::json!([blank.clone(), { "tool": "no_such_tool", "args": {} }]); + std::fs::write(&script, steps.to_string()).unwrap(); + + let failing = run_report(&dir, &script); + assert_eq!(failing.status.code(), Some(1), "{}", String::from_utf8_lossy(&failing.stderr)); + let report = reported(&failing); + assert_eq!(report["ok"], false, "{report}"); + assert_eq!(report["steps"][1]["status"], "error", "{report}"); + + std::fs::write(&script, serde_json::json!([blank]).to_string()).unwrap(); + let good = run_report(&dir, &script); + assert_eq!(good.status.code(), Some(0), "{}", String::from_utf8_lossy(&good.stderr)); + let report = reported(&good); + assert_eq!(report["ok"], true, "{report}"); + assert_eq!(report["steps"][0]["status"], "ok", "{report}"); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/apps/pdfcraft-cli/tests/it/main.rs b/apps/pdfcraft-cli/tests/it/main.rs index f0b87c97a7..30a6e25882 100644 --- a/apps/pdfcraft-cli/tests/it/main.rs +++ b/apps/pdfcraft-cli/tests/it/main.rs @@ -3,6 +3,7 @@ mod audit; mod automation_roots; mod batch; +mod batch_exit; mod check; mod edit; mod extract; From 5aff7c83db443352968d12bd1ad47b6fa9b28e52 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:10:55 +0200 Subject: [PATCH 17/43] M13 batch: test that failed folder runs leave no output or staging file (#6298) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/automation/tests/it/batch_failures.rs | 89 ++++++++++++++++++++ crates/automation/tests/it/main.rs | 1 + 2 files changed, 90 insertions(+) create mode 100644 crates/automation/tests/it/batch_failures.rs diff --git a/crates/automation/tests/it/batch_failures.rs b/crates/automation/tests/it/batch_failures.rs new file mode 100644 index 0000000000..f993d85eab --- /dev/null +++ b/crates/automation/tests/it/batch_failures.rs @@ -0,0 +1,89 @@ +//! A batch run that fails a file publishes only the completed outputs, and a +//! publish the file system refuses publishes nothing. + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::Session; +use serde_json::{Value, json}; + +fn result(auto: &mut Automation, args: Value) -> Value { + match auto.call("doc_batch", &args).unwrap().remove(0) { + Content::Json(value) => value, + _ => panic!("expected a report"), + } +} + +fn pdf() -> Vec { + Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone() +} + +/// Sets a folder's permission bits. +#[cfg(unix)] +fn set_mode(path: &std::path::Path, mode: u32) { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); +} + +/// Puts back the write permission the temporary folder needs before it is removed. +#[cfg(unix)] +struct RestoreMode(std::path::PathBuf); + +#[cfg(unix)] +impl Drop for RestoreMode { + fn drop(&mut self) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755)); + } +} + +#[test] +fn failed_inputs_publish_only_completed_outputs() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("input")).unwrap(); + std::fs::write(dir.path().join("input/good.pdf"), pdf()).unwrap(); + std::fs::write(dir.path().join("input/bad.pdf"), "not a pdf").unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","recursive":false})); + assert_eq!(report["completed"], 1, "{report}"); + assert_eq!(report["failed"], 1, "{report}"); + let files = report["files"].as_array().unwrap(); + assert_eq!(files.len(), 2, "{report}"); + let bad = files.iter().find(|file| file["path"].as_str().is_some_and(|path| path.ends_with("bad.pdf"))).unwrap(); + assert_eq!(bad["status"], "error", "{report}"); + assert!(bad["output"].is_null(), "a failed file reports no output: {report}"); + let names: Vec = + std::fs::read_dir(dir.path().join("out")).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect(); + // Publish stages its bytes under a dot-prefixed name in the output folder, so + // no entry carrying that prefix may survive the run. + for name in &names { + assert!(!name.starts_with('.'), "temporary name left behind: {name} ({names:?})"); + } + assert_eq!(names, vec!["good.pdf".to_string()], "only the completed output is published"); +} + +#[cfg(unix)] +#[test] +fn read_only_output_folder_publishes_nothing() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("input")).unwrap(); + std::fs::write(dir.path().join("input/good.pdf"), pdf()).unwrap(); + let out = dir.path().join("out"); + std::fs::create_dir(&out).unwrap(); + set_mode(&out, 0o555); + let _restore = RestoreMode(out.clone()); + // A root process ignores the mode bits, and then the assertions below would + // pass without testing the refusal. Fail loudly instead of skipping. + assert!(std::fs::write(out.join("probe"), b"").is_err(), "run as a non-root user: the read-only output folder accepted a new file"); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","recursive":false})); + assert_eq!(report["completed"], 0, "{report}"); + assert_eq!(report["failed"], 1, "{report}"); + let files = report["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{report}"); + // Publish cannot create its staging file in a folder it may not write, so + // the entry carries the system's error and no output. + assert_eq!(files[0]["status"], "error", "{report}"); + assert!(files[0]["output"].is_null(), "a refused publish reports no output: {report}"); + let error = files[0]["error"].as_str().unwrap(); + assert!(!error.is_empty(), "{report}"); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "the refused publish writes nothing into the read-only folder: {report}"); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 3122fe5825..39f2b8a30d 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -5,6 +5,7 @@ mod automation; mod automation_roots; mod backdrop; mod batch; +mod batch_failures; mod button_caption; mod form_bulk; mod hostile_args; From b1c1dbdad3d44b2517dafc418ba4690fe0b3f1fb Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:00 +0200 Subject: [PATCH 18/43] One known-answer case per implemented clause (#6301) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/preflight/tests/known_answers.rs | 403 ++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 crates/preflight/tests/known_answers.rs diff --git a/crates/preflight/tests/known_answers.rs b/crates/preflight/tests/known_answers.rs new file mode 100644 index 0000000000..23d503dc62 --- /dev/null +++ b/crates/preflight/tests/known_answers.rs @@ -0,0 +1,403 @@ +//! What `verify` answers for each clause it implements, pinned as a known answer. +//! +//! Each test builds a small file by hand, from the shape the clause describes rather than from +//! anything this repository saves, so a bug shared between the verifier and the writer cannot make a +//! case pass. Two things in the file are not hand-written: the output intent profiles come from +//! `icc::srgb()`, the crate's own sRGB generator, and the encrypted document in the 6.1.3 case is +//! encrypted by the object layer, because writing an encryption dictionary by hand would restate the +//! rules under test. +//! +//! The clause strings are the verifier's own citations, pinned here as `lib.rs` writes them. They +//! have not been compared with the text of the profile yet, so a number that does not name the rule +//! it is attached to is a finding about the verifier and not about this file. +//! +//! The positive case expects no issue under the clause and the negative case expects one; both are +//! asserted by clause string, so a later change that alters an answer fails here first. Cases where +//! the verifier is known to disagree with the standard are left out on purpose and recorded as gaps +//! where the change that adds them is reviewed. Nothing here is a conformance claim. + +use pdfcraft_cos::{Algorithm, Document, NewEncryption}; +use pdfcraft_preflight::{Level, convert, declared, icc, verify}; + +/// A document from hand-written objects and extra trailer entries (copied from the crate's unit +/// tests). +fn build(objs: &[&str], trailer_extra: &str) -> Document { + 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 {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(std::sync::Arc::new(out)).unwrap() +} + +/// The byte-order mark an XMP packet's `xpacket` instruction names. +const BOM: char = '\u{feff}'; + +/// The XMP packet identifying a file as `part`/`conformance`, with `title` as its `dc:title`. +fn xmp(part: u8, conformance: &str, title: Option<&str>) -> String { + let title = match title { + Some(t) => format!(" {t}\n"), + None => String::new(), + }; + format!( + r#" + + + + {part} + {conformance} +{title} + + +"# + ) +} + +/// The XMP packet of a PDF/A-2b file, with `title` as its title when one is given. +fn a2b_packet(title: Option<&str>) -> String { + xmp(2, "B", title) +} + +/// The catalog's `/Metadata` stream object holding `packet`. +fn xmp_object(packet: &str) -> String { + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{packet}\nendstream", packet.len()) +} + +/// A content stream object holding `content`. +fn content(content: &str) -> String { + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()) +} + +/// An ASCII-hex stream object holding `bytes`, with `dict` as its other entries. +fn hex_stream(dict: &str, bytes: &[u8]) -> String { + let hex: String = bytes.iter().map(|b| format!("{b:02X}")).collect(); + let data = format!("{hex}>"); + format!("<< /Filter /ASCIIHexDecode {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + +/// A 128-byte ICC header for a four-component profile, with no `/N` so the header decides. +fn cmyk_profile_header() -> Vec { + let mut header = vec![0u8; 128]; + header[0..4].copy_from_slice(&128u32.to_be_bytes()); + header[16..20].copy_from_slice(b"CMYK"); + header[20..24].copy_from_slice(b"XYZ "); + header[36..40].copy_from_slice(b"acsp"); + header +} + +/// The catalog's `/OutputIntents`: a `GTS_PDFA1` intent naming the profile in object `n`. +fn output_intent(n: usize) -> String { + let condition = "/OutputConditionIdentifier (sRGB IEC61966-2.1)"; + let intent = format!("<< /Type /OutputIntent /S /GTS_PDFA1 {condition} /DestOutputProfile {n} 0 R >>"); + format!("/OutputIntents [{intent}]") +} + +/// An embedded font program stream object, compressed with `filter` when one is given. Its bytes +/// are not read by any rule: a font program is data, not content. +fn font_program(filter: Option<&str>) -> String { + let filter = match filter { + Some(filter) => format!("/Filter {filter} "), + None => String::new(), + }; + format!("<< /Length1 4 {filter}/Length 4 >>\nstream\nabcd\nendstream") +} + +/// An image XObject that asks to be interpolated, or does not. +fn image(interpolate: bool) -> String { + format!( + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate {interpolate} /Length 1 >>\nstream\n\u{0}\nendstream" + ) +} + +/// A document whose catalog (object 1) carries `catalog_extra` and, when `packet` is given, the +/// metadata object 3; whose page tree (object 2) holds the single page `page` (object 4); and whose +/// objects 5 onwards are `more`, which `page` and `catalog_extra` refer to by number. An absent +/// `packet` leaves object 3 empty. The page must resolve, so a case that loses it fails instead of +/// passing because no rule ever reached it. +fn page_document(page: &str, packet: Option<&str>, catalog_extra: &str, more: &[&str], trailer_extra: &str) -> Document { + let metadata = match packet { + Some(packet) => xmp_object(packet), + None => "null".to_owned(), + }; + let catalog = match packet { + Some(_) => format!("<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R {catalog_extra} >>"), + None => format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + }; + let pages = "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 200] >>"; + let mut objs = vec![catalog.as_str(), pages, metadata.as_str(), page]; + objs.extend_from_slice(more); + let doc = build(&objs, trailer_extra); + assert_eq!(pdfcraft_model::pages(&doc).len(), 1, "the page of this case does not resolve"); + doc +} + +/// Asserts that `verify` reports `clause` for `doc`. +fn assert_reported(doc: &Document, level: Level, clause: &str) { + let issues = verify(doc, level); + assert!(issues.iter().any(|i| i.clause == clause), "no {clause} issue in {issues:#?}"); +} + +/// Asserts that `verify` reports nothing under `clause` for `doc`. +fn assert_not_reported(doc: &Document, level: Level, clause: &str) { + let found: Vec<_> = verify(doc, level).into_iter().filter(|i| i.clause == clause).collect(); + assert!(found.is_empty(), "{clause} was reported: {found:#?}"); +} + +#[test] +fn xmp_metadata_is_required_clause_6_6_2_1() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let identified = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_not_reported(&identified, Level::A2b, "6.6.2.1"); + assert_eq!(declared(&identified).pdfa, Some((2, "B".into()))); + let bare = page_document(page, None, "", &[], ""); + assert_reported(&bare, Level::A2b, "6.6.2.1"); +} + +#[test] +fn the_declared_part_and_conformance_must_be_the_level_clause_6_6_4() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let matching = a2b_packet(None); + let doc = page_document(page, Some(matching.as_str()), "", &[], ""); + assert_not_reported(&doc, Level::A2b, "6.6.4"); + for (part, conformance) in [(3, "B"), (2, "A")] { + let packet = xmp(part, conformance, None); + let doc = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_reported(&doc, Level::A2b, "6.6.4"); + assert_eq!(declared(&doc).pdfa, Some((part, conformance.to_owned()))); + } +} + +#[test] +fn the_xmp_title_must_match_the_information_dictionary_clause_6_6_3() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let info = "/Info << /Title (Report) >>"; + let matching = a2b_packet(Some("Report")); + let doc = page_document(page, Some(matching.as_str()), "", &[], info); + assert_not_reported(&doc, Level::A2b, "6.6.3"); + let differing = a2b_packet(Some("Draft")); + let doc = page_document(page, Some(differing.as_str()), "", &[], info); + assert_reported(&doc, Level::A2b, "6.6.3"); +} + +#[test] +fn the_trailer_must_not_name_encryption_clause_6_1_3() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let plain = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_not_reported(&plain, Level::A2b, "6.1.3"); + // An encrypted file's keys are not written out by hand here, so the /Encrypt dictionary comes + // from the object layer; the rule under test only asks whether the trailer names one. + let mut encrypted = page_document(page, Some(packet.as_str()), "", &[], ""); + let params = NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed: [7; 32], + }; + encrypted.set_encryption(¶ms).unwrap(); + assert_reported(&encrypted, Level::A2b, "6.1.3"); + let report = convert(&mut encrypted, Level::A2b).unwrap(); + assert!(!report.remaining.iter().any(|i| i.clause == "6.1.3"), "{:#?}", report.remaining); +} + +#[test] +fn device_colour_needs_an_output_intent_clause_6_2_3() { + let page = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << >> >>"; + let packet = a2b_packet(None); + let rgb = content("1 0 0 rg 10 10 20 20 re f"); + let doc = page_document(page, Some(packet.as_str()), "", &[rgb.as_str()], ""); + assert_reported(&doc, Level::A2b, "6.2.3"); + let none = content("q Q"); + let doc = page_document(page, Some(packet.as_str()), "", &[none.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.3"); +} + +#[test] +fn device_rgb_needs_an_rgb_output_intent_clause_6_2_4_3() { + let page = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << >> >>"; + let packet = a2b_packet(None); + let rgb = content("1 0 0 rg 10 10 20 20 re f"); + let srgb = hex_stream("/N 3", &icc::srgb()); + let doc = page_document(page, Some(packet.as_str()), &output_intent(6), &[rgb.as_str(), srgb.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.4.3"); + // The intent is there, so the neighbour rule about device colour without one stays quiet. + assert_not_reported(&doc, Level::A2b, "6.2.3"); + let cmyk = hex_stream("", &cmyk_profile_header()); + let doc = page_document(page, Some(packet.as_str()), &output_intent(6), &[rgb.as_str(), cmyk.as_str()], ""); + assert_reported(&doc, Level::A2b, "6.2.4.3"); +} + +#[test] +fn the_catalog_must_not_have_additional_actions_clause_6_5_2() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let plain = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_not_reported(&plain, Level::A2b, "6.5.2"); + let acting = page_document(page, Some(packet.as_str()), "/AA << /WC << /S /GoTo >> >>", &[], ""); + assert_reported(&acting, Level::A2b, "6.5.2"); +} + +#[test] +fn the_catalog_must_not_have_document_javascript_clause_6_5_1() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let plain = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_not_reported(&plain, Level::A2b, "6.5.1"); + // An empty name tree is enough: an action object here would report the same clause, and this + // case is about the catalog's own entry. + let tree = "/Names << /JavaScript << /Names [] >> >>"; + let scripted = page_document(page, Some(packet.as_str()), tree, &[], ""); + assert_reported(&scripted, Level::A2b, "6.5.1"); +} + +#[test] +fn the_form_must_not_ask_viewers_for_appearances_clause_6_4_1() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let plain = page_document(page, Some(packet.as_str()), "/AcroForm << /Fields [] >>", &[], ""); + assert_not_reported(&plain, Level::A2b, "6.4.1"); + let asking = "/AcroForm << /Fields [] /NeedAppearances true >>"; + let doc = page_document(page, Some(packet.as_str()), asking, &[], ""); + assert_reported(&doc, Level::A2b, "6.4.1"); +} + +#[test] +fn forbidden_annotation_types_are_reported_clause_6_3_1() { + let page = "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>"; + let packet = a2b_packet(None); + let link = "<< /Type /Annot /Subtype /Link /Rect [10 10 30 30] /P 4 0 R /F 4 >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[link], ""); + assert_not_reported(&doc, Level::A2b, "6.3.1"); + let sound = "<< /Type /Annot /Subtype /Sound /Rect [10 10 30 30] /P 4 0 R /F 4 >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[sound], ""); + assert_reported(&doc, Level::A2b, "6.3.1"); +} + +#[test] +fn annotations_must_print_and_not_hide_clause_6_3_2() { + let page = "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>"; + let packet = a2b_packet(None); + let appearance = "<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream"; + let printing = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 4 /AP << /N 6 0 R >> >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[printing, appearance], ""); + assert_not_reported(&doc, Level::A2b, "6.3.2"); + let unprinted = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 0 /AP << /N 6 0 R >> >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[unprinted, appearance], ""); + assert_reported(&doc, Level::A2b, "6.3.2"); + let hidden = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 6 /AP << /N 6 0 R >> >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[hidden, appearance], ""); + assert_reported(&doc, Level::A2b, "6.3.2"); +} + +#[test] +fn annotations_need_an_appearance_stream_clause_6_3_3() { + let page = "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>"; + let packet = a2b_packet(None); + let appearance = "<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream"; + let with = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 4 /AP << /N 6 0 R >> >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[with, appearance], ""); + assert_not_reported(&doc, Level::A2b, "6.3.3"); + let without = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 4 >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[without], ""); + assert_reported(&doc, Level::A2b, "6.3.3"); +} + +#[test] +fn lzw_compressed_streams_are_reported_clause_6_1_7_2() { + let page = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> >>"; + let packet = a2b_packet(None); + let text = content("BT /F1 12 Tf 10 10 Td (Hi) Tj ET"); + let font = "<< /Type /Font /Subtype /TrueType /BaseFont /Sans /FirstChar 72 /LastChar 105 /Widths [500 500] /FontDescriptor 7 0 R >>"; + let descriptor = "<< /Type /FontDescriptor /FontName /Sans /Flags 32 /FontFile2 8 0 R >>"; + let flate = font_program(Some("/FlateDecode")); + let doc = page_document(page, Some(packet.as_str()), "", &[text.as_str(), font, descriptor, flate.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.1.7.2"); + // The neighbour rule holds: the font still reads as embedded. + assert_not_reported(&doc, Level::A2b, "6.2.11.4"); + let lzw = font_program(Some("/LZWDecode")); + let doc = page_document(page, Some(packet.as_str()), "", &[text.as_str(), font, descriptor, lzw.as_str()], ""); + assert_reported(&doc, Level::A2b, "6.1.7.2"); +} + +#[test] +fn streams_must_not_read_external_files_clause_6_1_7_1() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let held = "<< /Length 4 >>\nstream\nabcd\nendstream"; + let doc = page_document(page, Some(packet.as_str()), "", &[held], ""); + assert_not_reported(&doc, Level::A2b, "6.1.7.1"); + // A stream object nothing refers to: the rule looks at every stream in the file. + let external = "<< /Length 4 /F (data.bin) >>\nstream\nabcd\nendstream"; + let doc = page_document(page, Some(packet.as_str()), "", &[external], ""); + assert_reported(&doc, Level::A2b, "6.1.7.1"); +} + +#[test] +fn images_must_not_be_interpolated_clause_6_2_8() { + let page = "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>"; + let packet = a2b_packet(None); + let drawing = content("q 10 0 0 10 0 0 cm /Im0 Do Q"); + let smooth = image(true); + let doc = page_document(page, Some(packet.as_str()), "", &[smooth.as_str(), drawing.as_str()], ""); + assert_reported(&doc, Level::A2b, "6.2.8"); + let crisp = image(false); + let doc = page_document(page, Some(packet.as_str()), "", &[crisp.as_str(), drawing.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.8"); +} + +#[test] +fn postscript_xobjects_are_reported_clause_6_2_9() { + let plain = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>"; + let with = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /XObject << /PS0 6 0 R >> >> >>"; + let packet = a2b_packet(None); + let drawing = content("q Q"); + let doc = page_document(plain, Some(packet.as_str()), "", &[drawing.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.9"); + let postscript = "<< /Type /XObject /Subtype /PS /Length 0 >>\nstream\n\nendstream"; + let doc = page_document(with, Some(packet.as_str()), "", &[drawing.as_str(), postscript], ""); + assert_reported(&doc, Level::A2b, "6.2.9"); +} + +#[test] +fn fonts_must_be_embedded_clause_6_2_11_4() { + let page = "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> >>"; + let packet = a2b_packet(None); + let embedded = "<< /Type /Font /Subtype /TrueType /BaseFont /Sans /FontDescriptor 6 0 R >>"; + let descriptor = "<< /Type /FontDescriptor /FontName /Sans /Flags 32 /FontFile2 7 0 R >>"; + let program = font_program(None); + let doc = page_document(page, Some(packet.as_str()), "", &[embedded, descriptor, program.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.11.4"); + let missing = "<< /Type /Font /Subtype /TrueType /BaseFont /Sans >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[missing], ""); + assert_reported(&doc, Level::A2b, "6.2.11.4"); +} + +/// A file built by hand, apart from its output profile, with everything the verifier asks for: an +/// XMP packet naming PDF/A-2b with a title that matches the document information, an sRGB output +/// intent and a blank page. It checks that the rules agree with each other, which is not by itself a +/// conformance claim. +#[test] +fn minimal_document_has_no_verifier_issues() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(Some("Report")); + let profile = hex_stream("/N 3", &icc::srgb()); + let info = "/Info << /Title (Report) >>"; + let mut doc = page_document(page, Some(packet.as_str()), &output_intent(5), &[profile.as_str()], info); + let issues = verify(&doc, Level::A2b); + assert!(issues.is_empty(), "{issues:#?}"); + assert_eq!(declared(&doc).pdfa, Some((2, "B".into()))); + assert_eq!(declared(&doc).output_intents, ["sRGB IEC61966-2.1"]); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.remaining.is_empty(), "{:#?}", report.remaining); + assert_eq!(declared(&doc).pdfa, Some((2, "B".into()))); +} From 9ff881e533be68ae1a9f8d556dea79d6df39a30a Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:05 +0200 Subject: [PATCH 19/43] The PDF/A panel reports its own checks, not conformance (#6302) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/ui-egui/src/dialogs/standards_ui.rs | 24 ++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/standards_ui.rs b/crates/ui-egui/src/dialogs/standards_ui.rs index 11c0d1fd50..de6052b05c 100644 --- a/crates/ui-egui/src/dialogs/standards_ui.rs +++ b/crates/ui-egui/src/dialogs/standards_ui.rs @@ -35,7 +35,10 @@ impl PdfCraftApp { self.pdfa_verify(); let left = self.pdfa.issues.as_ref().map_or(0, Vec::len); self.notify(if left == 0 { - crate::i18n::fmt(tl!("The document now conforms to {level}; save it to keep the changes"), &[("level", level.label())]) + crate::i18n::fmt( + tl!("PdfCraft's checks now find no problems for {level}. This is not a validator result. Save to keep the changes."), + &[("level", level.label())], + ) } else if left == 1 { crate::i18n::fmt(tl!("Fixed what could be fixed; 1 problem remains"), &[]) } else { @@ -91,7 +94,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool ui.label(egui::RichText::new(tl!("Verify to see what the document needs.")).color(t.text_muted)); } Some(v) if v.is_empty() => { - ui.label(crate::i18n::fmt(tl!("No problems found: the document conforms to {level}."), &[("level", app.pdfa.level.label())])); + ui.label(crate::i18n::fmt( + tl!("PdfCraft's checks found no problems for {level}. This is not a validator result."), + &[("level", app.pdfa.level.label())], + )); } Some(v) => { for i in v { @@ -129,3 +135,17 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool } close } + +#[cfg(test)] +mod wording_tests { + /// The Standards panel reports what PdfCraft's own checks found; only a run of an external + /// validator can state the stronger thing, and none is recorded here. + #[test] + fn no_standards_panel_wording_claims_conformance() { + let source = include_str!("standards_ui.rs"); + // Built in pieces so that this file does not match itself. + for phrase in [concat!("conform", "s to"), concat!("now ", "conforms")] { + assert!(!source.contains(phrase), "a standards message makes a claim only a validator run can make: {phrase}"); + } + } +} From 0d7ae1b0fc1fffa6d792193cddecff2920e03c46 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:10 +0200 Subject: [PATCH 20/43] Tests: a signed encrypted document's byte ranges cover its earlier revision (#6303) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/sign/tests/it/encrypted_signing.rs | 157 ++++++++++++++++++++++ crates/sign/tests/it/main.rs | 1 + 2 files changed, 158 insertions(+) create mode 100644 crates/sign/tests/it/encrypted_signing.rs diff --git a/crates/sign/tests/it/encrypted_signing.rs b/crates/sign/tests/it/encrypted_signing.rs new file mode 100644 index 0000000000..f1887ff358 --- /dev/null +++ b/crates/sign/tests/it/encrypted_signing.rs @@ -0,0 +1,157 @@ +//! The byte ranges of a signature on an encrypted document. + +use std::sync::Arc; + +use pdfcraft_cos::{Document, SaveOptions}; +use pdfcraft_sign::{SignOptions, Status, TrustStore, pkcs12, signatures}; + +fn data(name: &str) -> Vec { + std::fs::read(format!("{}/tests/data/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap() +} + +/// One page with text and an unsigned signature field "Approval". +fn fixture() -> Vec { + fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", &[]) +} + +/// [`fixture`] with its catalog replaced by `catalog` and `more` objects from number 7 on. +fn fixture_with(catalog: &[u8], more: &[&[u8]]) -> Vec { + let mut objs: Vec<&[u8]> = vec![ + catalog, + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>", + b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>", + ]; + objs.extend_from_slice(more); + write_pdf(&objs) +} + +/// A classic-xref PDF of `objs`, numbered from 1, with object 1 as the catalog. +fn write_pdf(objs: &[&[u8]]) -> 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", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + 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 open(b: &[u8]) -> Document { + Document::open(Arc::new(b.to_vec())).unwrap() +} + +fn opts() -> SignOptions { + SignOptions { + page: 0, + rect: Some([20.0, 100.0, 220.0, 150.0]), + reason: Some("I approve this document".into()), + location: Some("London".into()), + date: "D:20261002120000+01'00'".into(), + ..SignOptions::default() + } +} + +/// `fixture()` protected with an empty user password (opens without asking, like most +/// "secured" forms) and an owner password, then saved. +fn protected(alg: pdfcraft_cos::Algorithm, permissions: i32) -> Vec { + protected_with(alg, "", permissions) +} + +/// `fixture()` protected with the user password `user` and an owner password, then saved. +fn protected_with(alg: pdfcraft_cos::Algorithm, user: &str, permissions: i32) -> Vec { + let mut doc = open(&fixture()); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: alg, + user_password: user, + owner_password: "owner", + permissions, + encrypt_metadata: true, + seed: [17; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap() +} + +fn open_pw(b: &[u8], pw: Option<&str>) -> Document { + Document::open_with_password(Arc::new(b.to_vec()), pw).unwrap() +} + +const ALL_ALGORITHMS: [pdfcraft_cos::Algorithm; 4] = + [pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Rc4_128, pdfcraft_cos::Algorithm::Aes128, pdfcraft_cos::Algorithm::Aes256]; + +/// Where `needle` first appears in `haystack`. +fn find(haystack: &[u8], needle: &[u8], alg: pdfcraft_cos::Algorithm) -> usize { + haystack + .windows(needle.len()) + .position(|w| w == needle) + .unwrap_or_else(|| panic!("{alg:?}: {:?} is not in the added bytes", String::from_utf8_lossy(needle))) +} + +/// The four numbers of the signature dictionary's `/ByteRange` in the added revision. +fn byte_range(added: &[u8], alg: pdfcraft_cos::Algorithm) -> [usize; 4] { + let prefix = b"/ByteRange ["; + let at = find(added, prefix, alg) + prefix.len(); + let end = at + added[at..].iter().position(|b| *b == b']').unwrap_or_else(|| panic!("{alg:?}: the /ByteRange array is not closed")); + let text = std::str::from_utf8(&added[at..end]).unwrap_or_else(|_| panic!("{alg:?}: the /ByteRange numbers are not ASCII")); + let nums: Vec = + text.split_ascii_whitespace().map(|n| n.parse().unwrap_or_else(|_| panic!("{alg:?}: {n:?} is not a byte offset"))).collect(); + assert_eq!(nums.len(), 4, "{alg:?}: /ByteRange has {} numbers, not 4", nums.len()); + [nums[0], nums[1], nums[2], nums[3]] +} + +/// The signature dictionary's `/ByteRange` gives the offsets and lengths of the parts of the +/// file its digest covers. For a signature on an encrypted document that is the whole earlier +/// revision, its encrypted objects included, leaving out only the hex text of `/Contents`. +#[test] +fn signed_encrypted_documents_cover_their_earlier_revision() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + for alg in ALL_ALGORITHMS { + let base = protected(alg, -1); + let doc = open_pw(&base, None); + let signed = pdfcraft_sign::sign(&doc, &id, &opts()).unwrap(); + assert!(signed.starts_with(&base), "{alg:?}: an incremental update"); + let added = &signed[base.len()..]; + // The hole: the `<...>` hex string of `/Contents`. + let at = base.len() + find(added, b"/Contents <", alg); + let hole_start = at + b"/Contents ".len(); + let hole_end = hole_start + signed[hole_start..].iter().position(|b| *b == b'>').expect("a closed /Contents string") + 1; + let [a, b, c, d] = byte_range(added, alg); + assert_eq!(a, 0, "{alg:?}: the covered range starts at the start of the file"); + assert_eq!(b, hole_start, "{alg:?}: the covered range ends at {hole_start}, where /Contents begins"); + assert_eq!(c, hole_end, "{alg:?}: the second covered range starts at {hole_end}, where /Contents ends"); + assert_eq!(c + d, signed.len(), "{alg:?}: the covered range ends at the file's end ({c} + {d} of {})", signed.len()); + assert!(hole_start >= base.len(), "{alg:?}: the hole at {hole_start} comes after the whole earlier revision of {} bytes", base.len()); + assert_eq!(signed[hole_start], b'<', "{alg:?}: the hole opens with '<' at {hole_start}"); + assert_eq!(signed[hole_end - 1], b'>', "{alg:?}: the hole closes with '>' at {}", hole_end - 1); + assert!( + signed[hole_start + 1..hole_end - 1].iter().all(u8::is_ascii_hexdigit), + "{alg:?}: /Contents between {hole_start} and {hole_end} is hexadecimal" + ); + let sigs = signatures(&open_pw(&signed, None), &signed, &trust); + let s = sigs.iter().find(|s| s.signed).expect("a signed field"); + assert_eq!(s.status, Status::Valid, "{alg:?}: {:?}", s.details); + // One changed byte inside the covered range, in the earlier revision. + let mut tampered = signed.clone(); + let flip = base.len() - 1; + tampered[flip] ^= 1; + let sigs = signatures(&open_pw(&tampered, None), &tampered, &trust); + let signed_sigs: Vec<_> = sigs.iter().filter(|s| s.signed).collect(); + assert!(!signed_sigs.is_empty(), "{alg:?}: the tampered file still lists its signature"); + assert!( + signed_sigs.iter().all(|s| s.status == Status::Invalid), + "{alg:?}: the byte changed at {flip}, inside the covered range, makes every signature invalid" + ); + } +} diff --git a/crates/sign/tests/it/main.rs b/crates/sign/tests/it/main.rs index 7b8790850b..d526b01e7c 100644 --- a/crates/sign/tests/it/main.rs +++ b/crates/sign/tests/it/main.rs @@ -5,5 +5,6 @@ mod ber; mod chain; mod cms; mod crypto; +mod encrypted_signing; mod pdf; mod store_keys; From a2c6e63d0a4e8b461ecda2aebd7942bca5ca1c6f Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:15 +0200 Subject: [PATCH 21/43] Fuzz target for the JavaScript engine's script runner (#6304) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/js/fuzz/Cargo.toml | 24 +++++++++++++++++++++ crates/js/fuzz/fuzz_targets/run_script.rs | 26 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 crates/js/fuzz/Cargo.toml create mode 100644 crates/js/fuzz/fuzz_targets/run_script.rs diff --git a/crates/js/fuzz/Cargo.toml b/crates/js/fuzz/Cargo.toml new file mode 100644 index 0000000000..373028192e --- /dev/null +++ b/crates/js/fuzz/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pdfcraft-js-fuzz" +version = "0.0.0" +publish = false +edition = "2024" +license = "MIT OR Apache-2.0" + +[package.metadata] +cargo-fuzz = true + +# Detached from the PdfCraft workspace: run with `cargo +nightly fuzz run run_script` +# from crates/js/. +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +pdfcraft-js = { path = ".." } + +[[bin]] +name = "run_script" +path = "fuzz_targets/run_script.rs" +test = false +doc = false +bench = false diff --git a/crates/js/fuzz/fuzz_targets/run_script.rs b/crates/js/fuzz/fuzz_targets/run_script.rs new file mode 100644 index 0000000000..57ccb9ea66 --- /dev/null +++ b/crates/js/fuzz/fuzz_targets/run_script.rs @@ -0,0 +1,26 @@ +//! Feeds arbitrary bytes, read as a script, to the JavaScript run entry with fixed event and document values. +//! +//! The run's result is discarded: the target only checks that no input panics or hangs past the limits. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use pdfcraft_js::{DocInfo, Event, Limits, run}; + +/// The largest script the target runs (64 KiB); larger inputs are skipped. +const MAX_INPUT: usize = 64 << 10; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + let script = String::from_utf8_lossy(data).into_owned(); + let event = Event { + name: "Keystroke".into(), + kind: "Field".into(), + target: Some("field1".into()), + value: "value".into(), + change: String::new(), + will_commit: true, + }; + let _outcome = run(&script, &event, &DocInfo::default(), &[], &[], Limits::default()); +}); From c15c909745242013d81e9a7bcea451ff918dc7d7 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:21 +0200 Subject: [PATCH 22/43] Fuzz target for redaction apply (#6305) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/redact/fuzz/Cargo.toml | 25 ++++++++++++++++++++++++ crates/redact/fuzz/fuzz_targets/apply.rs | 24 +++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 crates/redact/fuzz/Cargo.toml create mode 100644 crates/redact/fuzz/fuzz_targets/apply.rs diff --git a/crates/redact/fuzz/Cargo.toml b/crates/redact/fuzz/Cargo.toml new file mode 100644 index 0000000000..716aa7715a --- /dev/null +++ b/crates/redact/fuzz/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "pdfcraft-redact-fuzz" +version = "0.0.0" +publish = false +edition = "2024" +license = "MIT OR Apache-2.0" + +[package.metadata] +cargo-fuzz = true + +# Detached from the PdfCraft workspace: run with `cargo +nightly fuzz run apply` +# from crates/redact/. +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +pdfcraft-cos = { path = "../../cos" } +pdfcraft-redact = { path = ".." } + +[[bin]] +name = "apply" +path = "fuzz_targets/apply.rs" +test = false +doc = false +bench = false diff --git a/crates/redact/fuzz/fuzz_targets/apply.rs b/crates/redact/fuzz/fuzz_targets/apply.rs new file mode 100644 index 0000000000..347e5ed120 --- /dev/null +++ b/crates/redact/fuzz/fuzz_targets/apply.rs @@ -0,0 +1,24 @@ +//! Feeds arbitrary bytes, read as a PDF, to the redaction marks and the apply entry. +//! +//! Results are discarded: the target checks that no input panics. +#![no_main] + +use std::sync::Arc; + +use libfuzzer_sys::fuzz_target; +use pdfcraft_cos::Document; +use pdfcraft_redact::{apply, marks}; + +/// The largest input the target opens (16 MiB); larger inputs are skipped. +const MAX_INPUT: usize = 16 << 20; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + let Ok(mut doc) = Document::open(Arc::new(data.to_vec())) else { + return; + }; + let _count = marks(&doc).len(); + let _result = apply(&mut doc, None); +}); From 9a95273c7c7f4485503f2d534ca5899948768bef Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:27 +0200 Subject: [PATCH 23/43] Fuzz targets for PDF/A verify and convert (#6308) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/preflight/fuzz/Cargo.toml | 32 +++++++++++++++++++ crates/preflight/fuzz/fuzz_targets/convert.rs | 21 ++++++++++++ crates/preflight/fuzz/fuzz_targets/verify.rs | 22 +++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 crates/preflight/fuzz/Cargo.toml create mode 100644 crates/preflight/fuzz/fuzz_targets/convert.rs create mode 100644 crates/preflight/fuzz/fuzz_targets/verify.rs diff --git a/crates/preflight/fuzz/Cargo.toml b/crates/preflight/fuzz/Cargo.toml new file mode 100644 index 0000000000..0e9e77f105 --- /dev/null +++ b/crates/preflight/fuzz/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "pdfcraft-preflight-fuzz" +version = "0.0.0" +publish = false +edition = "2024" +license = "MIT OR Apache-2.0" + +[package.metadata] +cargo-fuzz = true + +# Detached from the PdfCraft workspace: run with `cargo +nightly fuzz run verify` +# from crates/preflight/. +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +pdfcraft-cos = { path = "../../cos" } +pdfcraft-preflight = { path = ".." } + +[[bin]] +name = "verify" +path = "fuzz_targets/verify.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "convert" +path = "fuzz_targets/convert.rs" +test = false +doc = false +bench = false diff --git a/crates/preflight/fuzz/fuzz_targets/convert.rs b/crates/preflight/fuzz/fuzz_targets/convert.rs new file mode 100644 index 0000000000..6340bd585e --- /dev/null +++ b/crates/preflight/fuzz/fuzz_targets/convert.rs @@ -0,0 +1,21 @@ +//! Feeds arbitrary bytes to `convert` at level A2b; the report it returns is discarded. +#![no_main] + +use std::sync::Arc; + +use libfuzzer_sys::fuzz_target; +use pdfcraft_cos::Document; +use pdfcraft_preflight::{Level, convert}; + +/// Largest input the target parses; a longer input is dropped before `Document::open` copies it. +const MAX_INPUT: usize = 16 << 20; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + let Ok(mut doc) = Document::open(Arc::new(data.to_vec())) else { + return; + }; + let _ = convert(&mut doc, Level::A2b); +}); diff --git a/crates/preflight/fuzz/fuzz_targets/verify.rs b/crates/preflight/fuzz/fuzz_targets/verify.rs new file mode 100644 index 0000000000..7d02aa3ca4 --- /dev/null +++ b/crates/preflight/fuzz/fuzz_targets/verify.rs @@ -0,0 +1,22 @@ +//! Feeds arbitrary bytes to `verify` at both levels; the issue list it returns is discarded. +#![no_main] + +use std::sync::Arc; + +use libfuzzer_sys::fuzz_target; +use pdfcraft_cos::Document; +use pdfcraft_preflight::{Level, verify}; + +/// Largest input the target parses; a longer input is dropped before `Document::open` copies it. +const MAX_INPUT: usize = 16 << 20; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + let Ok(doc) = Document::open(Arc::new(data.to_vec())) else { + return; + }; + let _a2b = verify(&doc, Level::A2b); + let _a3b = verify(&doc, Level::A3b); +}); From 0c744357cb6e17bb5dfc5306eff7a58a2e652972 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:33 +0200 Subject: [PATCH 24/43] M13 batch: an action step that fails now fails its file (#6307) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/automation/tests/it/action_failures.rs | 66 +++++++++++++++++++ crates/automation/tests/it/main.rs | 1 + crates/engine/src/actions.rs | 8 +-- 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 crates/automation/tests/it/action_failures.rs diff --git a/crates/automation/tests/it/action_failures.rs b/crates/automation/tests/it/action_failures.rs new file mode 100644 index 0000000000..b487103a21 --- /dev/null +++ b/crates/automation/tests/it/action_failures.rs @@ -0,0 +1,66 @@ +//! A step that fails fails its file: the file is reported with an error and nothing is written +//! for it. A step that finds nothing to do is still not a failure. + +use std::sync::Arc; + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::Session; +use pdfcraft_engine::actions::{Action, Step, run_on}; +use serde_json::json; + +fn pdf() -> Vec { + Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone() +} + +fn one_step(step: Step) -> Action { + Action { name: "Custom".into(), description: String::new(), steps: vec![step], builtin: false } +} + +/// The standard fonts can't draw the character, so the watermark step fails and no file is produced. +#[test] +fn failing_watermark_step_fails_the_file_and_publishes_nothing() { + let r = run_on(&one_step(Step::AddWatermark("中".into())), "one.pdf", Arc::new(pdf()), |_, _| {}); + assert!(r.is_err(), "a failed step must fail its file: {r:?}"); +} + +/// A comment step on a document with no comments has nothing to do, which is not a failure. +#[test] +fn nothing_to_do_step_still_succeeds() { + let r = run_on(&one_step(Step::FlattenComments), "one.pdf", Arc::new(pdf()), |_, _| {}); + assert!(r.is_ok(), "a step with nothing to do is not a failure: {r:?}"); +} + +/// Through the tool: the failing file gets an error entry and no output, and the folder stays empty. +#[test] +fn action_run_marks_the_failing_file_as_error_without_output() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("in"); + std::fs::create_dir_all(&input).unwrap(); + let in_file = input.join("one.pdf"); + std::fs::write(&in_file, pdf()).unwrap(); + let out = dir.path().join("out"); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let reply = auto.call( + "action_run", + &json!({ + "paths": [in_file.to_string_lossy()], + "folder": out.to_string_lossy(), + "steps": [{ "step": "add_watermark", "arg": "中" }], + }), + ); + let report = match reply.unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected a report: {other:?}"), + }; + let entry = &report["files"][0]; + assert!(entry["error"].is_string(), "{report}"); + assert!(entry.get("output").is_none(), "{report}"); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing is published for a failed file"); +} + +/// A removal step with nothing to remove keeps its existing note and succeeds. +#[test] +fn removal_step_with_nothing_to_remove_still_succeeds() { + let r = run_on(&one_step(Step::RemoveHiddenInformation), "one.pdf", Arc::new(pdf()), |_, _| {}); + assert!(r.is_ok(), "nothing to remove is not a failure: {r:?}"); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 39f2b8a30d..a3cd55623c 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod action_failures; mod attachment_key; mod automation; mod automation_roots; diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs index 3e942f6a2f..249279e09d 100644 --- a/crates/engine/src/actions.rs +++ b/crates/engine/src/actions.rs @@ -197,14 +197,14 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> return Ok(None); } }; - // Steps that find nothing to do (no comments to flatten, …) aren't failures. A removal step - // that didn't remove is: the file would be passed on as cleaned with the data still in it. + // A step that finds nothing to do is not a failure. Any other error fails the file, so nothing is + // published for it: the file would otherwise be passed on without the step it was asked to apply. let removes = matches!(step, Step::RemoveHiddenInformation | Step::Sanitize); match s.apply(id, edit) { Ok(()) => log.push(step.label().to_string()), Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) if removes => log.push(format!("{}: nothing to remove", step.label())), - Err(e) if removes => return Err(e), - Err(e) => log.push(format!("{}: {e}", step.label())), + Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) => log.push(format!("{}: nothing to do", step.label())), + Err(e) => return Err(e), } Ok(None) } From ae893c1517d2d6281547c1bf902a403aa1094703 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:13:45 +0200 Subject: [PATCH 25/43] M13 batch: correct parity status and scope the no-replace sentence (#6299) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- docs/cli-batch.md | 4 +++- parity/acrobat-features.toml | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/cli-batch.md b/docs/cli-batch.md index ad2982bb14..dd0339cd12 100644 --- a/docs/cli-batch.md +++ b/docs/cli-batch.md @@ -42,12 +42,14 @@ only `.pdf` inputs are processed. Unsupported extensions are reported as skipped remaining files continue. Earlier successful outputs stay, and unprocessed files are listed as skipped. A file is published only after all its steps succeed. -Existing output names are never replaced, even by concurrent batch writers: +For `batch`, existing output names are never replaced, even by concurrent batch writers: `book.pdf`, `book (2).pdf`, and so on. A temporary file in the destination folder is published without overwriting. Sources are never moved or deleted. An output folder inside an input folder is excluded from discovery, including on reruns. The output folder cannot equal or contain a selected input folder. +The Action Wizard (`action_run`) and folder OCR (`ocr_recognize_files`) do not give this guarantee yet. + The report contains `completed`, `failed`, `skipped`, `stopped` and `files`. Each file records its status, output and step results, or the error/skip reason. The `batch` command exits nonzero on any file failure while still printing the diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9635898230..2e82a0c430 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7844,11 +7844,11 @@ title = "Optimize multiple files" area = "K" tier = "P2" milestone = "M13" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_batch"] +evidence = ["apps/pdfcraft-cli/tests/it/batch.rs::cli_batch_reports_partial_failure_and_continues_into_subfolders"] +notes = "Headless and CLI only: doc_batch with the optimize step (`pdfcraft-cli batch --steps optimize`, or `convert,optimize` as the cited test runs) over files and folders. No GUI batch dialog, no report file, no parallel workers, no resume. See docs/cli-batch.md." [[feature]] id = "optimize.preflight" @@ -9452,11 +9452,11 @@ title = "Run guided actions on files" area = "N" tier = "P1" milestone = "M13" -status = "shipped" +status = "partial" commands = ["actions.wizard"] tools = ["action_list", "action_run"] evidence = ["crates/ui-egui/tests/it/actions.rs::desktop_actions_preserve_duplicate_names_and_results_from_earlier_runs", "crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/it/automation.rs::actions_through_tools", "crates/ui-egui/tests/it/actions.rs::run_and_create_actions"] -notes = "Desktop output filenames receive numbered suffixes when occupied, preserving every batch result and previous outputs. Pick an action and files; results go to a chosen folder, in the background with progress." +notes = "Desktop output filenames receive numbered suffixes when occupied, preserving every batch result and previous outputs. Pick an action and files; results go to a chosen folder, in the background with progress. Known gap: a failing non-removal step still publishes its output as success (crates/engine/src/actions.rs:200-208)." [[feature]] id = "misc.action-wizard-create" From f9c150c17c2f717e0795b0820b1d5d4fba79f660 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:32:24 +0200 Subject: [PATCH 26/43] Add IBAN matching to Search and Redact patterns (#4332) * Add IBAN matching to Search and Redact patterns The Pattern/PATTERNS/from_id interface is consumed generically by redact_mark and the UI pattern combobox through the engine re-exports, so one new variant and matcher exposes IBAN in Search and Redact everywhere. A unit test shows valid compact and spaced IBANs are marked while wrong check digits, short runs, unknown countries, letter check digits and token-glued IBANs are not. Add an Iban variant to Pattern, list it in PATTERNS, handle it in label, id and find, and add an iban matcher with a country-code length table, compact or group-of-four-spaced acceptance, alphanumeric start and end boundaries and ISO 7064 mod-97 check-digit validation. Test: iban_matches_compact_spaced_and_rejects_near_matches failed before the change and passes after. * Address review for pull request 4332 * Address review for pull request 4332 * review: address CodeRabbit findings - patterns.rs: fix SWIFT registry IBAN lengths EG 27->29, LY 27->25, SD 24->18 and add NI 28, BI 27, DJ 27, OM 23 (crates/redact/src/patterns.rs:545,549,551,558-561) - patterns.rs: state table source as SWIFT IBAN registry without claiming full ISO 13616 coverage (crates/redact/src/patterns.rs:475) - patterns.rs: regression test with valid registry examples for EG, LY, SD, NI, BI, DJ, OM and a wrong-length EG rejection (crates/redact/src/patterns.rs:892) * Complete the IBAN country table and accept spaces only between groups The country table now lists all 89 countries of the SWIFT IBAN registry (release 99) as one sorted constant. Honduras, Russia, Yemen, Falkland Islands, Mongolia and Somalia were missing. A space is accepted only after a full group of four characters, so a code followed by ordinary words ("BA37 leaves at noon") ends the run instead of being tried against the check digits. Tests: the registry example of every country matches compact and printed, changes to one digit are rejected, and the table and the examples agree. * Advertise the IBAN pattern in the redact_mark tool schema The pattern enum in the advertised schema was kept by hand and still listed phone, email, credit-card, ssn and date, so clients that obey the schema could not select iban, which the runtime already accepted. The enum is now built from the engine's pattern registry, so it cannot drift from it again, and the description names iban. Tests: tools/list advertises exactly the registry's pattern ids and names each in the description, and an iban mark made through the tool covers the spaced German sample and nothing else. --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/automation/src/tools.rs | 12 +- crates/automation/tests/it/automation.rs | 48 +++ crates/redact/src/patterns.rs | 358 ++++++++++++++++++++++- 3 files changed, 414 insertions(+), 4 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index bf2abea185..54d94ff4bf 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -5,6 +5,8 @@ use serde_json::{Value, json}; +use pdfcraft_engine::REDACT_PATTERNS; + use crate::ToolError; #[derive(Clone, Debug)] @@ -30,6 +32,12 @@ fn pages(what: &str) -> Value { json!({ "type": "array", "items": { "type": "integer", "minimum": 1 }, "minItems": 1, "description": format!("1-based page numbers {what}.") }) } +/// The Search & Redact pattern ids, from the engine's registry, so the advertised schema cannot +/// drift from the patterns the runtime accepts. +fn pattern_enum() -> Value { + json!({ "type": "string", "enum": REDACT_PATTERNS.iter().map(|p| p.id()).collect::>() }) +} + fn path(what: &str) -> Value { json!({ "type": "array", "items": { "type": "integer", "minimum": 1 }, "description": format!("{what}: 1-based positions from the top level, e.g. [2, 1] = the first child of the second bookmark.") }) } @@ -605,7 +613,7 @@ pub fn tools() -> Vec { t( "redact_mark", "Mark for redaction", - "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", + "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date, iban: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", ) .with(schema( json!({ @@ -616,7 +624,7 @@ pub fn tools() -> Vec { "words": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "maxItems": 1000 }, "case_sensitive": { "type": "boolean", "description": "Match letter case for find or words (default false)." }, "whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." }, - "pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] }, + "pattern": pattern_enum(), "whole_pages": { "type": "boolean" }, "pages": pages("to search or mark (default: all); mutually exclusive with page, and not valid with rect"), "overlay": { "type": "string" }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6f493b824d..5ed32360fd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -1715,6 +1715,25 @@ fn mcp_default_lists_every_tool_and_has_no_meta_tools() { assert_eq!(rpc(&mut s, 3, "tools/call", json!({ "name": "tool_search" }))["error"]["code"], -32602); } +#[cfg(feature = "mcp")] +#[test] +/// `tools/list` advertises exactly the Search & Redact patterns the registry supports, in `pattern` +/// and by name in the description, so a schema-obeying client can select every one of them. +fn the_advertised_redact_mark_schema_offers_every_pattern() { + use pdfcraft_engine::REDACT_PATTERNS; + let registry: Vec<&str> = REDACT_PATTERNS.iter().map(|p| p.id()).collect(); + let mut s = McpServer::new(Automation::new()); + let list = rpc(&mut s, 1, "tools/list", json!({})); + let mark = list["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "redact_mark").expect("redact_mark is listed"); + let advertised: Vec<&str> = + mark["inputSchema"]["properties"]["pattern"]["enum"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); + assert_eq!(advertised, registry, "the advertised pattern enum must be the registry's ids"); + let description = mark["description"].as_str().unwrap(); + for id in ®istry { + assert!(description.contains(id), "the description must name {id}: {description}"); + } +} + #[cfg(feature = "mcp")] #[test] fn mcp_compact_lists_the_core_tools_and_two_meta_tools() { @@ -4809,6 +4828,35 @@ fn redacting_italian_international_slash_numbers_through_tools() { std::fs::remove_dir_all(dir).unwrap(); } +#[test] +/// A client obeying the advertised schema can select iban, and the mark made through the tool +/// covers the spaced number and nothing else. +fn redacting_an_iban_through_tools() { + let table = tools(); + let advertised: Vec<&str> = table.iter().find(|t| t.name == "redact_mark").unwrap().input_schema["properties"]["pattern"]["enum"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(advertised.contains(&"iban"), "the advertised redact_mark schema must offer iban: {advertised:?}"); + let dir = workdir("redact-iban"); + std::fs::write(dir.join("invoice.txt"), "Pay DE89 3704 0044 0532 0130 00 today\nAccount 12345678 is ours\nTotal due").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "invoice.txt" }))["doc"].as_u64().unwrap(); + let marked = ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "iban" })); + assert_eq!(marked["marked"], 1, "the spaced German sample IBAN needs one mark"); + assert!(page_text(&mut a, doc)[0].contains("DE89 3704 0044 0532 0130 00"), "marks alone remove nothing"); + assert_eq!(ok(&mut a, "redact_apply", json!({ "doc": doc }))["applied"], 1); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "redacted.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "redacted.pdf" }))["doc"].as_u64().unwrap(); + let text = &page_text(&mut a, reopened)[0]; + assert!(!text.contains("DE89") && !text.contains("0532"), "{text}"); + assert!(text.lines().any(|line| line.trim() == "Pay"), "only the label before the number remains: {text}"); + assert!(text.contains("Account 12345678") && text.contains("Total due"), "text without an IBAN remains: {text}"); + std::fs::remove_dir_all(dir).unwrap(); +} + /// One page with the text "Secret 1234" and a Redact annotation that has neither a usable /// QuadPoints array nor a Rect, so there is no area to redact. fn redact_pdf_with_unreadable_mark() -> Vec { diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index 59e623a7e7..9662602671 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -1,5 +1,5 @@ //! Search & Redact patterns (Acrobat's Find Text ▸ Patterns): phone numbers, email addresses, -//! credit card numbers, US Social Security numbers and dates. Each matcher works on a page's +//! credit card numbers, US Social Security numbers, dates and IBANs. Each matcher works on a page's //! text as characters and returns character ranges; callers map them to glyphs and areas. use std::ops::Range; @@ -11,9 +11,10 @@ pub enum Pattern { CreditCard, Ssn, Date, + Iban, } -pub const PATTERNS: [Pattern; 5] = [Pattern::Phone, Pattern::Email, Pattern::CreditCard, Pattern::Ssn, Pattern::Date]; +pub const PATTERNS: [Pattern; 6] = [Pattern::Phone, Pattern::Email, Pattern::CreditCard, Pattern::Ssn, Pattern::Date, Pattern::Iban]; impl Pattern { pub fn label(self) -> &'static str { @@ -23,6 +24,7 @@ impl Pattern { Pattern::CreditCard => "Credit Cards", Pattern::Ssn => "Social Security Numbers", Pattern::Date => "Dates", + Pattern::Iban => "IBAN Numbers", } } @@ -33,6 +35,7 @@ impl Pattern { Pattern::CreditCard => "credit-card", Pattern::Ssn => "ssn", Pattern::Date => "date", + Pattern::Iban => "iban", } } @@ -397,6 +400,173 @@ fn date(s: &[char], i: usize) -> Option { Some(k2 + y - i) } +/// An IBAN (ISO 13616): a two-letter country code, two check digits and the rest +/// of the country's exact length in letters and digits, either compact or in the +/// printed groups of four. A space is accepted only after a full group, so an +/// ordinary word after a code ("BA37 leaves at noon") ends the run. Valid iff the +/// number is one modulo 97 after moving the first four characters to the end. The +/// country code must be in the registry, and an alphanumeric character glued to +/// the end rejects the match. +fn iban(s: &[char], i: usize) -> Option { + if !(s.get(i).copied().is_some_and(|c| c.is_ascii_alphabetic()) + && s.get(i + 1).copied().is_some_and(|c| c.is_ascii_alphabetic()) + && s.get(i + 2).copied().is_some_and(|c| c.is_ascii_digit()) + && s.get(i + 3).copied().is_some_and(|c| c.is_ascii_digit())) + { + return None; + } + // Not the tail of a longer alphanumeric run. + if s.get(i.wrapping_sub(1)).is_some_and(|c| c.is_alphanumeric()) { + return None; + } + let country: String = s.iter().skip(i).take(2).map(char::to_ascii_uppercase).collect(); + let len = iban_length(&country)?; + // The IBAN's characters uppercased, read up to the country's length. + let mut digits = Vec::with_capacity(len); + let mut j = i; + let mut after_space = false; + while digits.len() < len { + match s.get(j) { + Some(c) if c.is_ascii_alphanumeric() => { + digits.push(c.to_ascii_uppercase()); + after_space = false; + } + // A single space between two groups of four. + Some(&' ') if digits.len() % 4 == 0 && !after_space => after_space = true, + _ => return None, + } + j += 1; + } + // Reject an IBAN glued directly to trailing alphanumerics ("...00ABC"). + // A space-separated suffix ("...00 123") is not part of the IBAN. + if s.get(j).is_some_and(|c| c.is_alphanumeric()) { + return None; + } + iban_check(&digits).then_some(j - i) +} + +/// Whether `digits` is a valid IBAN: move the first four characters to the end, +/// replace letters by two digits (A = 10, ..., Z = 35), and the number must be +/// one modulo 97. +fn iban_check(digits: &[char]) -> bool { + let mut rem = 0u32; + for c in digits.iter().skip(4).chain(digits.iter().take(4)).copied() { + let value = iban_value(c); + // A letter stands for two decimal digits (A = 10, ..., Z = 35), + // a digit for one. + rem = if c.is_ascii_digit() { (rem * 10 + value) % 97 } else { (rem * 100 + value) % 97 }; + } + rem == 1 +} + +/// The value of an IBAN character: a digit is itself, a letter is 10 (A) to 35 (Z). +fn iban_value(c: char) -> u32 { + match c { + '0'..='9' => c as u32 - '0' as u32, + _ => c as u32 - 'A' as u32 + 10, + } +} + +/// The exact length of an IBAN for each country code of the SWIFT IBAN registry +/// (release 99, December 2024), counting letters and digits only. A country code +/// that is not listed has no IBANs, so nothing starting with it is matched. +const IBAN_LENGTHS: [(&str, usize); 89] = [ + ("AD", 24), + ("AE", 23), + ("AL", 28), + ("AT", 20), + ("AZ", 28), + ("BA", 20), + ("BE", 16), + ("BG", 22), + ("BH", 22), + ("BI", 27), + ("BR", 29), + ("BY", 28), + ("CH", 21), + ("CR", 22), + ("CY", 28), + ("CZ", 24), + ("DE", 22), + ("DJ", 27), + ("DK", 18), + ("DO", 28), + ("EE", 20), + ("EG", 29), + ("ES", 24), + ("FI", 18), + ("FK", 18), + ("FO", 18), + ("FR", 27), + ("GB", 22), + ("GE", 22), + ("GI", 23), + ("GL", 18), + ("GR", 27), + ("GT", 28), + ("HN", 28), + ("HR", 21), + ("HU", 28), + ("IE", 22), + ("IL", 23), + ("IQ", 23), + ("IS", 26), + ("IT", 27), + ("JO", 30), + ("KW", 30), + ("KZ", 20), + ("LB", 28), + ("LC", 32), + ("LI", 21), + ("LT", 20), + ("LU", 20), + ("LV", 21), + ("LY", 25), + ("MC", 27), + ("MD", 24), + ("ME", 22), + ("MK", 19), + ("MN", 20), + ("MR", 27), + ("MT", 31), + ("MU", 30), + ("NI", 28), + ("NL", 18), + ("NO", 15), + ("OM", 23), + ("PK", 24), + ("PL", 28), + ("PS", 29), + ("PT", 25), + ("QA", 29), + ("RO", 24), + ("RS", 22), + ("RU", 33), + ("SA", 24), + ("SC", 31), + ("SD", 18), + ("SE", 24), + ("SI", 19), + ("SK", 24), + ("SM", 27), + ("SO", 23), + ("ST", 25), + ("SV", 28), + ("TL", 23), + ("TN", 24), + ("TR", 26), + ("UA", 29), + ("VA", 22), + ("VG", 24), + ("XK", 20), + ("YE", 30), +]; + +/// The length of an IBAN of `country`, if the registry lists it. +fn iban_length(country: &str) -> Option { + IBAN_LENGTHS.iter().find(|(code, _)| *code == country).map(|&(_, len)| len) +} + /// Character ranges of `text` matching `pattern`. pub fn find(pattern: Pattern, text: &[char]) -> Vec> { let f: fn(&[char], usize) -> Option = match pattern { @@ -405,6 +575,7 @@ pub fn find(pattern: Pattern, text: &[char]) -> Vec> { Pattern::CreditCard => credit_card, Pattern::Ssn => ssn, Pattern::Date => date, + Pattern::Iban => iban, }; let mut out = Vec::new(); let mut i = 0; @@ -665,4 +836,187 @@ mod tests { let s: Vec = "\u{130}\u{131}I\u{15E}".chars().collect(); assert_eq!(s.iter().copied().map(fold).collect::(), "iii\u{15F}"); } + + #[test] + fn iban_matches_compact_spaced_and_rejects_near_matches() { + assert_eq!(Pattern::from_id("iban").map(Pattern::id), Some("iban")); + + // Spaced and compact IBANs match, a wrong check digit is rejected ("DE89 + // 3704 0044 0532 0130 00" and "DE89370400440532013000" match, "...01" + // does not). + if let Some(iban_pattern) = Pattern::from_id("iban") { + // Spaced IBAN (valid German IBAN: DE89 3704 0044 0532 0130 00) + assert_eq!(found(iban_pattern, "DE89 3704 0044 0532 0130 00"), vec!["DE89 3704 0044 0532 0130 00"]); + // Compact IBAN (same number, no spaces) + assert_eq!(found(iban_pattern, "DE89370400440532013000"), vec!["DE89370400440532013000"]); + // Wrong check digit should not match (DE89...01 instead of DE89...00) + assert_eq!(found(iban_pattern, "DE89 3704 0044 0532 0130 01"), Vec::::new()); + // Near match: IBAN followed by extra digits should match only the IBAN + assert_eq!(found(iban_pattern, "DE89 3704 0044 0532 0130 00 123"), vec!["DE89 3704 0044 0532 0130 00"]); + } + } + + #[test] + fn iban_rejects_unknown_country_code_wrong_length_and_glued_tail() { + if let Some(iban_pattern) = Pattern::from_id("iban") { + // Unknown country code: the check digits pass mod-97, but ISO 13616 defines no length for XX. + assert_eq!(found(iban_pattern, "XX65000000000000000000"), Vec::::new()); + // DE is 22 characters long. This 15-character number passes mod-97 but has + // the wrong national length, so it must be rejected. + assert_eq!(found(iban_pattern, "DE8300000000000"), Vec::::new()); + // A valid IBAN glued directly to trailing alphanumerics is not an IBAN. + assert_eq!(found(iban_pattern, "DE89370400440532013000ABC"), Vec::::new()); + assert_eq!(found(iban_pattern, "ABCDE89370400440532013000"), Vec::::new()); + // Letters in place of the two check digits. + assert_eq!(found(iban_pattern, "DEAB 3704 0044 0532 0130 00"), Vec::::new()); + // A space-separated suffix does not glue to the IBAN. + assert_eq!(found(iban_pattern, "DE89 3704 0044 0532 0130 00 123"), vec!["DE89 3704 0044 0532 0130 00"]); + } + } + + #[test] + fn iban_accepts_spaces_only_between_groups_of_four() { + // A code followed by ordinary words: the words end the run, so nothing is marked. + for s in [ + "Flight BA37 leaves at noon from gate four", + "Flight BA30 leaves at noon", + "Gate BA45 leaves at noon", + "BA72 have water are word", + "GB00 their an her make that", + "SI30 write can all more", + ] { + assert_eq!(found(Pattern::Iban, s), Vec::::new(), "{s}"); + } + // A valid IBAN with a space in the wrong place, or two spaces, is not marked. + for s in ["DE89 3704 0044 0532 013 000", "DE89 3704 0044 0532 0 13000", "DE8 9370 4004 4053 2013 000", "DE89 3704 0044 0532 0130 00"] { + assert_eq!(found(Pattern::Iban, s), Vec::::new(), "{s}"); + } + // Any group boundary may be a space or not, and the IBAN inside a sentence is marked alone. + assert_eq!(found(Pattern::Iban, "DE893704 0044 0532013000"), vec!["DE893704 0044 0532013000"]); + assert_eq!(found(Pattern::Iban, "Pay to DE89 3704 0044 0532 0130 00 today"), vec!["DE89 3704 0044 0532 0130 00"]); + } + + /// One example IBAN per country of the SWIFT IBAN registry (release 99). + const IBAN_REGISTRY_EXAMPLES: [&str; IBAN_LENGTHS.len()] = [ + "AD1200012030200359100100", + "AE070331234567890123456", + "AL47212110090000000235698741", + "AT611904300234573201", + "AZ21NABZ00000000137010001944", + "BA391290079401028494", + "BE68539007547034", + "BG80BNBG96611020345678", + "BH67BMAG00001299123456", + "BI4210000100010000332045181", + "BR1800360305000010009795493C1", + "BY13NBRB3600900000002Z00AB00", + "CH9300762011623852957", + "CR05015202001026284066", + "CY17002001280000001200527600", + "CZ6508000000192000145399", + "DE89370400440532013000", + "DJ2100010000000154000100186", + "DK5000400440116243", + "DO28BAGR00000001212453611324", + "EE382200221020145685", + "EG380019000500000000263180002", + "ES9121000418450200051332", + "FI2112345600000785", + "FK88SC123456789012", + "FO6264600001631634", + "FR1420041010050500013M02606", + "GB29NWBK60161331926819", + "GE29NB0000000101904917", + "GI75NWBK000000007099453", + "GL8964710001000206", + "GR1601101250000000012300695", + "GT82TRAJ01020000001210029690", + "HN88CABF00000000000250005469", + "HR1210010051863000160", + "HU42117730161111101800000000", + "IE29AIBK93115212345678", + "IL620108000000099999999", + "IQ98NBIQ850123456789012", + "IS140159260076545510730339", + "IT60X0542811101000000123456", + "JO94CBJO0010000000000131000302", + "KW81CBKU0000000000001234560101", + "KZ86125KZT5004100100", + "LB62099900000001001901229114", + "LC55HEMM000100010012001200023015", + "LI21088100002324013AA", + "LT121000011101001000", + "LU280019400644750000", + "LV80BANK0000435195001", + "LY83002048000020100120361", + "MC5811222000010123456789030", + "MD24AG000225100013104168", + "ME25505000012345678951", + "MK07250120000058984", + "MN121234123456789123", + "MR1300020001010000123456753", + "MT84MALT011000012345MTLCAST001S", + "MU17BOMM0101101030300200000MUR", + "NI45BAPR00000013000003558124", + "NL91ABNA0417164300", + "NO9386011117947", + "OM810180000001299123456", + "PK36SCBL0000001123456702", + "PL61109010140000071219812874", + "PS92PALS000000000400123456702", + "PT50000201231234567890154", + "QA58DOHB00001234567890ABCDEFG", + "RO49AAAA1B31007593840000", + "RS35260005601001611379", + "RU0304452522540817810538091310419", + "SA0380000000608010167519", + "SC18SSCB11010000000000001497USD", + "SD2129010501234001", + "SE4550000000058398257466", + "SI56263300012039086", + "SK3112000000198742637541", + "SM86U0322509800000000270100", + "SO211000001001000100141", + "ST68000100010051845310112", + "SV62CENR00000000000000700025", + "TL380080012345678910157", + "TN5910006035183598478831", + "TR330006100519786457841326", + "UA213223130000026007233566001", + "VA59001123000012345678", + "VG96VPVG0000012345678901", + "XK051212012345678906", + "YE15CBYE0001018861234567891234", + ]; + + /// `iban` in the printed form: groups of four separated by single spaces. + fn printed(iban: &str) -> String { + iban.chars().collect::>().chunks(4).map(|group| group.iter().collect::()).collect::>().join(" ") + } + + #[test] + fn iban_registry_examples_cover_every_country_and_length() { + let from_examples: Vec<(&str, usize)> = IBAN_REGISTRY_EXAMPLES.iter().map(|e| (&e[..2], e.len())).collect(); + assert_eq!(from_examples.as_slice(), IBAN_LENGTHS.as_slice()); + } + + #[test] + fn iban_matches_the_registry_example_of_every_country() { + for example in IBAN_REGISTRY_EXAMPLES { + assert_eq!(found(Pattern::Iban, example), vec![example], "compact {example}"); + let spaced = printed(example); + assert_eq!(found(Pattern::Iban, &spaced), vec![spaced.clone()], "printed {spaced}"); + } + } + + #[test] + fn iban_rejects_a_registry_example_with_one_digit_changed() { + for example in IBAN_REGISTRY_EXAMPLES { + // The last digit becomes the next digit, so the country, the length and the layout stay the same. + let Some(at) = example.rfind(|c: char| c.is_ascii_digit()) else { continue }; + let digit = example[at..=at].parse::().unwrap_or(0); + let changed = format!("{}{}{}", &example[..at], (digit + 1) % 10, &example[at + 1..]); + assert_eq!(found(Pattern::Iban, &changed), Vec::::new(), "{changed}"); + } + } } From b3a90f07f3986f63cc2b19a986f7eb81de86958f Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:32:34 +0200 Subject: [PATCH 27/43] Parity: titles and the PDF/X row say only what is implemented (#6300) * Parity: titles and the PDF/X row say only what is implemented * Parity: the PDF/X row says removal matches the pdfxid: prefix only Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- docs/pdf-spec-parity.md | 5 +++-- parity/acrobat-features.toml | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md index aefc959698..e25c30067e 100644 --- a/docs/pdf-spec-parity.md +++ b/docs/pdf-spec-parity.md @@ -2,7 +2,7 @@ > Integration update 2026-10-30: lossless JBIG2 generic and exact-copy symbol coding are included; no lossy substitution. Combined tests passed at bdf5620; the subsequent review fixes require fresh CI. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (§7.4: JBIG2 decoding in `pdfcraft-filters`, so redaction clears JBIG2 scans; earlier, §7.4: redaction decodes CCITT fax images, Group 3 on bytes aside) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (subset standards: the PDF/A rows name 2b and 3b only, and the PDF/X row says what is there — the identification is carried through Combine and can be removed; no verifier, no converter) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against what Acrobat Pro implements. This is PdfCraft's equivalent of a "geometry" or "codec" checklist: @@ -121,7 +121,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | PDF/A-1a/b (ISO 19005-1) | verify, convert | none | | | PDF/A-2a/b/u, PDF/A-3a/b/u | verify, convert | **partial**: 2b and 3b structural rules and conversion (sRGB intent; not CMYK) | | | PDF/A-4, 4e, 4f | verify, convert | none | | -| PDF/X-1a, X-3, X-4 (ISO 15930) | verify, convert | none | | +| PDF/X-1a, X-3, X-4 (ISO 15930) | verify, convert | **partial**: PDF/X declarations are carried through Combine and can be removed (`standards_remove_id`, which matches the `pdfxid:` prefix only); no verifier, no converter | | | PDF/UA-1, PDF/UA-2 (ISO 14289) | verify (Preflight), checker | Accessibility Checker (32 rules, Acrobat's own), no PDF/UA verification | | | PDF/E, PDF/VT | verify, convert | none | | | Preflight (profiles, fixups, reports) | hundreds of profiles | none | | @@ -132,6 +132,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Subset standards: the PDF/A rows name 2b and 3b only, and the PDF/X row says what is there — the identification is carried through Combine and can be removed; no verifier, no converter. No feature status changed | | 2026-10-30 | minor | §7.4: JBIG2 decoding (`hayro-jbig2`) in `pdfcraft-filters`, which `Document::decoded_image` uses with the image's `/JBIG2Globals`: export reads JBIG2 behind other filters too, and redaction clears pixels in JBIG2 images instead of removing them whole (one whose page isn't its size still is) | | 2026-10-26 | minor | §7.4: redaction clears pixels in CCITT fax images through the CCITT decoder, instead of removing them whole (Group 4 and Group 3 off bytes; Group 3 on bytes is still removed whole, as the bootstrap renderer may draw it as other rows than the decoder reads) | | 2026-10-26 | minor | §7.4: CCITT decoding (`hayro-ccitt`) in `pdfcraft-filters`, for export and the Optimizer | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2e82a0c430..f71546b731 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7996,7 +7996,7 @@ notes = "" [[feature]] id = "optimize.pdfa-validate" -title = "Verify PDF/A compliance (1-4)" +title = "Verify PDF/A compliance (2b and 3b so far; 1, 2a, 2u, 3a, 3u, 4 not yet)" area = "K" tier = "P1" milestone = "M11" @@ -8008,7 +8008,7 @@ notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent [[feature]] id = "optimize.pdfa-convert" -title = "Save as PDF/A (1b, 2b, 3b, 4)" +title = "Save as PDF/A (2b and 3b so far)" area = "K" tier = "P1" milestone = "M11" From bb9da1c1c2b65c8cc80fbb3f1ebc282e33da5412 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:32:39 +0200 Subject: [PATCH 28/43] Convert replaces a removed forbidden annotation, so it is not reported again (#6306) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/preflight/src/lib.rs | 3 + crates/preflight/tests/convert_annotations.rs | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 crates/preflight/tests/convert_annotations.rs diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1fb7272b33..5fdec67fac 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -877,6 +877,9 @@ pub fn convert(doc: &mut Document, level: Level) -> Result Document { + 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 {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(std::sync::Arc::new(out)).unwrap() +} + +/// One page whose `/Annots` lists a Sound annotation (forbidden by clause 6.3.1) and a link. +fn page_with_a_forbidden_sound_annotation() -> Document { + build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Sound /Rect [10 10 30 30] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Link /Rect [40 40 60 60] /P 3 0 R /F 4 /Border [0 0 0] >>", + ], + "", + ) +} + +/// The forbidden annotation is reported before `convert` and by neither `convert` nor a later +/// `verify` afterwards: the object it took off the page is no longer an annotation. +#[test] +fn convert_removes_the_forbidden_annotation_clause_6_3_1() { + let mut doc = page_with_a_forbidden_sound_annotation(); + let before = verify(&doc, Level::A2b); + assert!(before.iter().any(|i| i.clause == "6.3.1"), "the fixture starts with the finding: {before:#?}"); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(!report.remaining.iter().any(|i| i.clause == "6.3.1"), "6.3.1 remains after convert: {:#?}", report.remaining); + let after = verify(&doc, Level::A2b); + assert!(!after.iter().any(|i| i.clause == "6.3.1"), "6.3.1 is reported after convert: {after:#?}"); +} + +/// The page keeps its other annotation: only the forbidden one leaves `/Annots`. +#[test] +fn convert_keeps_the_other_annotations_on_the_page() { + let mut doc = page_with_a_forbidden_sound_annotation(); + convert(&mut doc, Level::A2b).unwrap(); + let page = doc.get(ObjRef::new(3, 0)); + let dict = page.as_dict().expect("the page is a dictionary"); + let annots = doc.resolve(dict.get(b"Annots").expect("the page has /Annots")); + let kept: Vec = annots.as_array().expect("the page's /Annots is an array").iter().filter_map(Object::as_ref).collect(); + assert_eq!(kept, vec![ObjRef::new(5, 0)]); +} From ee39c7e2d01787c70c521c2a7392790498606cf0 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:32:44 +0200 Subject: [PATCH 29/43] Issue #5081: assembly at revision 3 and later needs bit 11; high-quality print, accessibility and form fill follow the revision (#6309) * Issue #5081: assembly at revision 3 and later needs bit 11; high-quality print, accessibility and form fill follow the revision * Permissions: at revision 2, bits 11 and 12 mean nothing Table 22 defines them for revision 3 and later. A revision 2 file with bit 4 clear and bit 11 set was given page changes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/crypt/src/lib.rs | 26 +++- crates/crypt/src/tests.rs | 6 +- crates/crypt/tests/it/main.rs | 1 + crates/crypt/tests/it/permissions_revision.rs | 126 ++++++++++++++++++ crates/engine/src/document.rs | 2 +- crates/engine/src/tests.rs | 4 +- 6 files changed, 152 insertions(+), 13 deletions(-) create mode 100644 crates/crypt/tests/it/permissions_revision.rs diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index ff92d9af66..e697bc8553 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -82,17 +82,24 @@ pub enum Auth { User, } -/// Permission bits from `/P` (§7.6.4.2, Table 22). Owners have every permission. +/// Permission flags from `/P`, read the way the handler's revision reads them (ISO 32000-1:2008, +/// Table 22). Owners have every permission. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Permissions { pub bits: i32, pub owner: bool, + /// The `/R` value of the handler that read `bits`. + pub revision: i64, } impl Permissions { fn bit(&self, n: u32) -> bool { self.owner || (self.bits >> (n - 1)) & 1 == 1 } + /// Revision 3 and later read the full flag list of Table 22; revision 2 has its own. + fn is_revision_3_or_later(&self) -> bool { + self.revision >= 3 + } pub fn print(&self) -> bool { self.bit(3) } @@ -107,17 +114,22 @@ impl Permissions { self.bit(6) } pub fn fill_forms(&self) -> bool { - self.bit(9) || self.bit(6) + if self.is_revision_3_or_later() { self.bit(9) || self.bit(6) } else { self.bit(6) } } + /// Revision 2 covers accessibility with the copy flag (bit 5); revision 3 and later with bit 10. pub fn extract_for_accessibility(&self) -> bool { - self.bit(10) + if self.is_revision_3_or_later() { self.bit(10) } else { self.bit(5) } } - /// Insert, rotate or delete pages; create bookmarks and thumbnails. + /// Insert, rotate or delete pages; create bookmarks and thumbnails. Revision 3 and later grant + /// it by bit 11 alone. Table 22 defines bit 11 for those revisions only, so at revision 2 the + /// modify flag (bit 4) covers it. pub fn assemble(&self) -> bool { - self.bit(11) || self.bit(4) + if self.is_revision_3_or_later() { self.bit(11) } else { self.bit(4) } } + /// Revision 3 and later need bit 3 as well as bit 12. Table 22 defines bit 12 for those + /// revisions only: at revision 2 the print flag (bit 3) is all there is. pub fn print_high_quality(&self) -> bool { - self.bit(12) + if self.is_revision_3_or_later() { self.print() && self.bit(12) } else { self.print() } } } @@ -250,7 +262,7 @@ impl SecurityHandler { /// edited `/P` grants nothing extra. Without a readable `/Perms`, `/P` applies as before. pub fn permissions(&self) -> Permissions { let bits = self.perms_p().map_or(self.dict.p, |p| self.dict.p & p); - Permissions { bits, owner: self.auth == Auth::Owner } + Permissions { bits, owner: self.auth == Auth::Owner, revision: self.dict.r } } /// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`. diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs index 6064176d78..09a88ed129 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -240,11 +240,11 @@ fn unsupported_handlers_are_reported() { #[test] fn permission_bits_follow_table_22() { - let p = Permissions { bits: -3904, owner: false }; // 0xFFFFF0C0: only reserved bits set + let p = Permissions { bits: -3904, owner: false, revision: 3 }; // 0xFFFFF0C0: only reserved bits set assert!(!p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.assemble()); - let p = Permissions { bits: -1, owner: false }; + let p = Permissions { bits: -1, owner: false, revision: 3 }; assert!(p.print() && p.modify() && p.copy() && p.annotate() && p.assemble() && p.fill_forms()); - let p = Permissions { bits: 0, owner: true }; + let p = Permissions { bits: 0, owner: true, revision: 3 }; assert!(p.modify(), "owners ignore /P"); } diff --git a/crates/crypt/tests/it/main.rs b/crates/crypt/tests/it/main.rs index 8b39bf1956..e7742bbd75 100644 --- a/crates/crypt/tests/it/main.rs +++ b/crates/crypt/tests/it/main.rs @@ -1,4 +1,5 @@ //! The crate's integration tests, linked into one test binary. +mod permissions_revision; mod proptests; mod qpdf_literals; diff --git a/crates/crypt/tests/it/permissions_revision.rs b/crates/crypt/tests/it/permissions_revision.rs new file mode 100644 index 0000000000..bad709cd51 --- /dev/null +++ b/crates/crypt/tests/it/permissions_revision.rs @@ -0,0 +1,126 @@ +//! The permission accessors of `pdfcraft_crypt::Permissions`, checked against the flag table of +//! ISO 32000-1:2008 (Table 22). Expected values come from that table only. +//! +//! Bit n has the mask `1 << (n - 1)`. Readers use bits 3 to 6 and 9 to 12 and ignore the rest. + +use pdfcraft_crypt::{Algorithm, Auth, NewEncryption, Permissions, SecurityHandler, create}; + +const ID: &[u8] = b"0123456789abcdef"; +const T: bool = true; +const F: bool = false; + +/// The eight accessors in this order: print, high-quality print, modify, copy, accessibility, +/// annotate, fill forms, assemble. +fn cells(p: &Permissions) -> [bool; 8] { + [p.print(), p.print_high_quality(), p.modify(), p.copy(), p.extract_for_accessibility(), p.annotate(), p.fill_forms(), p.assemble()] +} + +/// Creates a file with `flags` at revision `revision`, opens it with the user password, and +/// returns the user's permissions. +fn user_view(alg: Algorithm, revision: i64, flags: i32) -> Permissions { + let made = create( + &NewEncryption { algorithm: alg, user_password: "user", owner_password: "owner", permissions: flags, encrypt_metadata: true, seed: [42; 32] }, + ID, + ) + .expect("the handler is created"); + assert_eq!(made.dict().r, revision, "the algorithm writes revision {revision}"); + let opened = SecurityHandler::open(made.dict().clone(), ID, Some("user")).expect("the user password opens the file"); + assert_eq!(opened.auth(), Auth::User, "the user password gives user access"); + opened.permissions() +} + +/// Revision 3 and later: each row is a flag word and the expected eight cells. +const REVISION_3_ROWS: &[(&str, i32, [bool; 8])] = &[ + ("every meaningful flag set, ignored flags clear", -196, [T, T, T, T, T, T, T, T]), + ("every flag set, ignored flags set too", -1, [T, T, T, T, T, T, T, T]), + ("reserved flags only", -4096, [F, F, F, F, F, F, F, F]), + ("ignored flags set, meaningful flags clear", -3901, [F, F, F, F, F, F, F, F]), + ("modify flag clear", -9, [T, T, F, T, T, T, T, T]), + ("copy flag clear", -17, [T, T, T, F, T, T, T, T]), + ("annotate flag clear", -33, [T, T, T, T, T, F, T, T]), + ("annotate and fill-existing flags clear", -289, [T, T, T, T, T, F, F, T]), + ("annotate flag set, fill-existing flag clear", -257, [T, T, T, T, T, T, T, T]), + ("accessibility flag clear", -513, [T, T, T, T, F, T, T, T]), + ("high-quality flag clear, print flag set", -2049, [T, F, T, T, T, T, T, T]), + ("bits 3 to 12 set, reserved bits clear", 4092, [T, T, T, T, T, T, T, T]), + ("positive flag word, bit 32 clear", 2_147_483_647, [T, T, T, T, T, T, T, T]), + ("revision 2 example read at revision 3", -44, [T, T, F, T, T, F, T, T]), +]; + +#[test] +fn revision_three_rows_hold_through_a_created_and_reopened_file() { + for (name, bits, want) in REVISION_3_ROWS { + assert_eq!(cells(&user_view(Algorithm::Rc4_128, 3, *bits)), *want, "{name}: flag word {bits:#010x}"); + } +} + +#[test] +fn owner_access_gives_every_flag() { + let made = create( + &NewEncryption { + algorithm: Algorithm::Rc4_128, + user_password: "user", + owner_password: "owner", + permissions: 0, + encrypt_metadata: true, + seed: [42; 32], + }, + ID, + ) + .expect("the handler is created"); + assert_eq!(made.auth(), Auth::Owner, "the owner password gives owner access"); + assert_eq!(cells(&made.permissions()), [T; 8]); +} + +#[test] +fn revision_two_print_modify_copy_and_annotate_follow_the_table() { + for (bits, want) in [(-4, [T, T, T, T]), (-44, [T, F, T, F]), (-17, [T, T, F, T])] { + let p = user_view(Algorithm::Rc4_40, 2, bits); + assert_eq!([p.print(), p.modify(), p.copy(), p.annotate()], want, "revision 2, flag word {bits}"); + } +} + +#[test] +fn revision_two_with_every_flag_set_grants_accessibility_and_form_filling() { + let p = user_view(Algorithm::Rc4_40, 2, -4); + assert!(p.extract_for_accessibility(), "bit 5 is set, and at revision 2 it covers accessibility"); + assert!(p.fill_forms(), "bit 6 is set"); +} + +#[test] +fn revision_three_assembly_needs_its_own_flag() { + let p = user_view(Algorithm::Rc4_128, 3, -1025); + assert!(p.modify(), "bit 4 is set"); + assert!(!p.assemble(), "assembly needs bit 11; bit 4 alone does not grant it"); +} + +#[test] +fn revision_three_high_quality_print_needs_the_print_flag() { + let p = user_view(Algorithm::Rc4_128, 3, -5); + assert!(!p.print(), "bit 3 is clear"); + assert!(!p.print_high_quality(), "high-quality print needs bit 3 and bit 12"); +} + +#[test] +fn revision_two_accessibility_follows_the_copy_flag() { + let p = user_view(Algorithm::Rc4_40, 2, -17); + assert!(!p.copy(), "bit 5 is clear"); + assert!(!p.extract_for_accessibility(), "at revision 2 accessibility is bit 5"); +} + +#[test] +fn revision_two_form_filling_ignores_bit_nine() { + let p = user_view(Algorithm::Rc4_40, 2, -44); + assert!(!p.annotate(), "bit 6 is clear"); + assert!(!p.fill_forms(), "at revision 2 form filling is bit 6 alone"); +} + +#[test] +fn revision_two_ignores_the_flags_only_later_revisions_define() { + // Bit 4 is clear and bit 11 set: bit 11 means nothing at revision 2, so assembly stays refused. + let p = user_view(Algorithm::Rc4_40, 2, -44); + assert!(!p.assemble(), "at revision 2 assembly is bit 4 alone"); + // Bit 3 is set and bit 12 clear: printing at revision 2 has one quality. + let p = user_view(Algorithm::Rc4_40, 2, -2049); + assert!(p.print_high_quality(), "at revision 2 high-quality print is bit 3 alone"); +} diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index fa98a18417..279dc8d563 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -539,7 +539,7 @@ impl Document { _ => "Attachments only", }; let pending = editor.cos.encryption_changed(); - let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; + let permissions = if pending { pdfcraft_cos::Permissions { owner: false, ..h.permissions() } } else { h.permissions() }; Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a20314c2e0..86fa6495ad 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -623,7 +623,7 @@ fn a_3d_model_needs_the_comments_permission_and_changes_the_comments() { activation: Activation3d::default(), name: String::new(), }; - let p = pdfcraft_cos::Permissions { bits: 0, owner: false }; + let p = pdfcraft_cos::Permissions { bits: 0, owner: false, revision: 3 }; assert_eq!(check_permission(&edit, &p), Err(EditError::NotPermitted("comments"))); assert_eq!(crate::history::scope_of(&edit), crate::history::Scope::Comments); } @@ -6048,7 +6048,7 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { assert!(session.get(id).unwrap().measurements().unwrap().measurements.is_empty()); session.redo(id).unwrap(); assert_eq!(session.get(id).unwrap().bytes, before); - let p = pdfcraft_cos::Permissions { bits: 0, owner: false }; + let p = pdfcraft_cos::Permissions { bits: 0, owner: false, revision: 3 }; assert_eq!(check_permission(&Edit::AddMeasurement(m), &p), Err(EditError::NotPermitted("comments"))); assert_eq!( check_permission( From f16b5d9331da5583fa9abdb973010f358aa5c60f Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:52:06 +0200 Subject: [PATCH 30/43] M13 batch: test that results hold no planted secret, and record network-crate reachability (#6297) * M13 batch: test that results hold no planted secret, and record network-crate reachability * netcheck: list socket2, report a walk past the cap, test by the gate rule socket2 can open sockets, so it belongs in the list. A walk past the cap now fails instead of reading as unreachable. The baseline test follows check_rows, so a dependency update that moves a path no longer fails cargo test. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/automation/tests/it/batch_secrets.rs | 84 +++++ crates/automation/tests/it/main.rs | 1 + xtask/baselines/network-crates.json | 74 +++++ xtask/src/main.rs | 6 + xtask/src/netcheck.rs | 344 ++++++++++++++++++++ 5 files changed, 509 insertions(+) create mode 100644 crates/automation/tests/it/batch_secrets.rs create mode 100644 xtask/baselines/network-crates.json create mode 100644 xtask/src/netcheck.rs diff --git a/crates/automation/tests/it/batch_secrets.rs b/crates/automation/tests/it/batch_secrets.rs new file mode 100644 index 0000000000..08223f0955 --- /dev/null +++ b/crates/automation/tests/it/batch_secrets.rs @@ -0,0 +1,84 @@ +//! Tool results and errors from the folder tools carry no known test password. +//! +//! The passwords are set on the document and passed to the tool, and must never come back out: a +//! result that echoes one has leaked it into logs, transcripts and MCP callers. + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::{Changes, Edit, Protection, Session}; +use serde_json::{Value, json}; + +/// The known test passwords; neither may appear in a result or an error. +const OPEN_PASSWORD: &str = "PLANT-OPEN-5f2c9a"; +const OWNER_PASSWORD: &str = "PLANT-OWNER-71de0b"; + +/// A one-page PDF locked with the known test passwords. +/// +/// The open password is the only way in. The batch bates tool rewrites page content, and +/// `Changes::None` forbids that, so this fixture permits every change except extracting pages; +/// the restrictions stay real, since the open password never lifts them. +fn protected_pdf() -> Vec { + let mut session = Session::new(); + let bytes = session.create_from_text("Batch secrets", "Planted password test").unwrap(); + let id = session.open("locked.pdf", None, bytes, None).unwrap(); + session + .apply( + id, + Edit::Protect(Protection { + open_password: Some(OPEN_PASSWORD.into()), + permissions_password: Some(OWNER_PASSWORD.into()), + changes: Changes::AnyExceptExtract, + ..Default::default() + }), + ) + .unwrap(); + session.save_bytes(id).unwrap().as_ref().clone() +} + +fn json_result(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut content) => match content.remove(0) { + Content::Json(value) => value, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(error) => panic!("{tool} {args}: {error}"), + } +} + +#[test] +fn results_and_errors_hold_no_planted_password_for_bates_or_batch() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("locked.pdf"), protected_pdf()).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + + // The right open password: numbering succeeds and reports the numbers only. + let numbered = + json_result(&mut a, "doc_bates_multi", json!({"files":[{"input":"locked.pdf","output":"locked-numbered.pdf","password":OPEN_PASSWORD}]})); + assert_eq!(numbered["completed"], 1, "{numbered}"); + assert_eq!(numbered["files"][0]["status"], "ok", "{numbered}"); + // The report carries the per-file entry the Bates tool builds, so a field added to that entry + // lands in the text checked below. + assert_eq!(numbered["files"][0]["first"], 1, "{numbered}"); + assert!(numbered["files"][0]["output"].as_str().is_some_and(|path| path.ends_with("locked-numbered.pdf")), "{numbered}"); + assert!(dir.path().join("locked-numbered.pdf").exists(), "the numbered copy is written"); + + // The wrong open password: the tool refuses the file with an error. + let refused = match a + .call("doc_bates_multi", &json!({"files":[{"input":"locked.pdf","output":"locked-refused.pdf","password":"not-the-open-password"}]})) + { + Err(error) => error.to_string(), + Ok(value) => panic!("a wrong open password must be refused: {value:?}"), + }; + + // The folder job refuses the encrypted file without a password, in its per-file report. + let batch = json_result(&mut a, "doc_batch", json!({"paths":["locked.pdf"],"steps":["optimize"],"out_dir":"out"})); + assert_eq!(batch["failed"], 1, "{batch}"); + assert_eq!(batch["files"][0]["status"], "error", "{batch}"); + assert!(batch["files"][0]["error"].as_str().is_some_and(|text| !text.is_empty()), "{batch}"); + assert!(!dir.path().join("out/locked.pdf").exists(), "a refused file publishes nothing"); + + for report in [serde_json::to_string(&numbered).unwrap(), refused, serde_json::to_string(&batch).unwrap()] { + for secret in [OPEN_PASSWORD, OWNER_PASSWORD] { + assert!(!report.contains(secret), "a result or error echoed a planted password: {report}"); + } + } +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index a3cd55623c..13f24013cd 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -7,6 +7,7 @@ mod automation_roots; mod backdrop; mod batch; mod batch_failures; +mod batch_secrets; mod button_caption; mod form_bulk; mod hostile_args; diff --git a/xtask/baselines/network-crates.json b/xtask/baselines/network-crates.json new file mode 100644 index 0000000000..13f24c43a7 --- /dev/null +++ b/xtask/baselines/network-crates.json @@ -0,0 +1,74 @@ +[ + { + "crate": "pdfcraft-cli", + "name": "hyper", + "reachable": true, + "path": "pdfcraft-cli -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper" + }, + { + "crate": "pdfcraft-cli", + "name": "reqwest", + "reachable": true, + "path": "pdfcraft-cli -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest" + }, + { + "crate": "pdfcraft-cli", + "name": "socket2", + "reachable": true, + "path": "pdfcraft-cli -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper-util -> socket2" + }, + { + "crate": "pdfcraft-cli", + "name": "tokio", + "reachable": true, + "path": "pdfcraft-cli -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> tokio" + }, + { + "crate": "pdfcraft-automation", + "name": "hyper", + "reachable": true, + "path": "pdfcraft-automation -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper" + }, + { + "crate": "pdfcraft-automation", + "name": "reqwest", + "reachable": true, + "path": "pdfcraft-automation -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest" + }, + { + "crate": "pdfcraft-automation", + "name": "socket2", + "reachable": true, + "path": "pdfcraft-automation -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper-util -> socket2" + }, + { + "crate": "pdfcraft-automation", + "name": "tokio", + "reachable": true, + "path": "pdfcraft-automation -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> tokio" + }, + { + "crate": "pdfcraft-engine", + "name": "hyper", + "reachable": true, + "path": "pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper" + }, + { + "crate": "pdfcraft-engine", + "name": "reqwest", + "reachable": true, + "path": "pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest" + }, + { + "crate": "pdfcraft-engine", + "name": "socket2", + "reachable": true, + "path": "pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper-util -> socket2" + }, + { + "crate": "pdfcraft-engine", + "name": "tokio", + "reachable": true, + "path": "pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> tokio" + } +] diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 8e3255d5e6..01beb95ecf 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -10,6 +10,7 @@ mod gates; mod layers; mod license_check; mod macos_integration; +mod netcheck; mod parity; mod screenshots; mod version; @@ -35,6 +36,11 @@ const COMMANDS: &[(&str, &str, Command)] = &[ ("check", "Robustness sweep over corpus/ with pdfcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check), ("fuzz", "Mutation fuzzing of open/render/edit/save in child processes; findings in fuzz-out/ (--time 300)", fuzz::run), ("parity", "Validate parity/acrobat-features.toml against the registry, tools and tests; report progress (--json, --partial)", parity::run), + ( + "netcheck", + "Record which network-capable crates the batch crates can reach in Cargo.lock, and fail on any reachable pair not in xtask/baselines/network-crates.json", + netcheck::run, + ), ( "macos-integration", "Report macOS bundle, Quick Look, Spotlight and PDF integration diagnostics without mutating system state", diff --git a/xtask/src/netcheck.rs b/xtask/src/netcheck.rs new file mode 100644 index 0000000000..0d29a461f1 --- /dev/null +++ b/xtask/src/netcheck.rs @@ -0,0 +1,344 @@ +//! `cargo xtask netcheck`: which network-capable crates the batch path can reach. +//! +//! The batch folder job runs `pdfcraft-cli`, which drives `pdfcraft-automation` over +//! `pdfcraft-engine`; none of them may open a socket. That promise is only as strong as the +//! dependency graph behind those three packages, so this gate walks the resolved graph recorded +//! in `Cargo.lock` and asks, for each of them and each crate that can open a socket, whether that +//! crate is reachable from it. The answer is compared with a reviewed baseline in +//! `xtask/baselines/network-crates.json`: a reachable pair the baseline does not list fails. +//! +//! A lockfile records the resolved dependencies of a package without their kind and without a +//! target: normal, build and development edges look the same, and a dependency only one platform +//! uses is listed beside the rest. The answer here is therefore a superset of what a normal-level, +//! per-target walk would give. It may name a crate that is only a build or a development +//! dependency, or one that only another platform uses; it does not miss one, so it errs toward +//! failing and never toward passing. `--update-baseline` rewrites the baseline; review that diff +//! line by line. +//! +//! The walk is in process, over the lockfile text: it starts no other program and reads no +//! network. This gate is local on purpose, not part of `cargo xtask ci`, which would be a +//! workflow change. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::path::Path; + +use anyhow::{Context, bail}; +use serde::{Deserialize, Serialize}; + +use crate::gates::root; + +/// The packages the batch path is built from. +const PACKAGES: &[&str] = &["pdfcraft-cli", "pdfcraft-automation", "pdfcraft-engine"]; + +/// Crates that can open a socket: HTTP clients, TLS stacks, async runtimes, socket wrappers and +/// libcurl bindings. +const NETWORK_CRATES: &[&str] = &["curl", "hyper", "isahc", "minreq", "native-tls", "reqwest", "socket2", "tokio", "ureq"]; + +/// The resolved dependency graph of the workspace, relative to the repository root. +const LOCKFILE: &str = "Cargo.lock"; + +/// The reviewed baseline, relative to the repository root. +const BASELINE: &str = "xtask/baselines/network-crates.json"; + +/// The most packages one walk will visit. A lock file has a few hundred packages; the cap stops a +/// damaged file from turning the walk into a long loop. +const MAX_VISITED: usize = 500_000; + +/// What the lock file says about one package and one network-capable crate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct Row { + /// The package whose dependencies were walked. + #[serde(rename = "crate")] + package: String, + /// The network-capable crate asked about. + name: String, + /// Whether the package reaches the crate in the lock file graph. + reachable: bool, + /// The chain from the package to the crate, ` -> ` separated; empty when unreachable. + #[serde(default)] + path: String, +} + +/// The first word of a dependency entry: `"name"`, `"name 1.2.3"` and `"name 1.2.3 (source)"` +/// all name the same package. +fn entry_name(entry: &str) -> &str { + entry.split_whitespace().next().unwrap_or_default() +} + +/// The package a dependency line names, when the line is one. +fn dependency_name(line: &str) -> Option<&str> { + let quoted = line.strip_prefix('"')?; + let end = quoted.find('"')?; + let name = entry_name("ed[..end]); + (!name.is_empty()).then_some(name) +} + +/// The names in a `dependencies = ["a", "b"]` line, that is, the text between its brackets. +fn inline_names(value: &str) -> Vec<&str> { + value.split(',').filter_map(|item| dependency_name(item.trim())).collect() +} + +/// One edge of the graph: `package` depends on `dependency`. +fn add_edge(graph: &mut BTreeMap>, package: &str, dependency: &str) { + graph.entry(package.to_owned()).or_default().insert(dependency.to_owned()); +} + +/// The dependency graph recorded by a lock file, keyed by package name. +/// +/// Edges are by name: a lock file can hold two versions of one crate, and the walk only asks +/// whether a name can be reached at all. A file that names no package is an error rather than an +/// empty graph, so a format change fails loudly instead of reporting nothing reachable. +fn parse_lockfile(text: &str) -> anyhow::Result>> { + let mut graph: BTreeMap> = BTreeMap::new(); + let mut current: Option = None; + let mut in_list = false; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if in_list { + if line.starts_with(']') { + in_list = false; + } else if let Some(name) = dependency_name(line) + && let Some(package) = current.as_deref() + { + add_edge(&mut graph, package, name); + } + continue; + } + if line.starts_with("[[") { + current = None; + continue; + } + if let Some(name) = line.strip_prefix("name = \"").and_then(|rest| rest.strip_suffix('"')) { + graph.entry(name.to_owned()).or_default(); + current = Some(name.to_owned()); + continue; + } + if let Some(value) = line.strip_prefix("dependencies = [") { + match value.strip_suffix(']') { + Some(inner) => { + for name in inline_names(inner) { + if let Some(package) = current.as_deref() { + add_edge(&mut graph, package, name); + } + } + } + None => in_list = true, + } + } + } + if graph.is_empty() { + bail!("{LOCKFILE} names no package; the lock file format changed or the file is empty"); + } + Ok(graph) +} + +/// The shortest chain from `from` to `to` in the graph, both ends included. A walk past +/// [`MAX_VISITED`] is an error: it has no answer, and "unreachable" would pass the gate. +fn shortest_path(graph: &BTreeMap>, from: &str, to: &str) -> anyhow::Result>> { + let mut parents: BTreeMap = BTreeMap::new(); + let mut queue: VecDeque = VecDeque::from([from.to_owned()]); + let mut visited = 0usize; + while let Some(node) = queue.pop_front() { + visited = visited.saturating_add(1); + if visited > MAX_VISITED { + bail!("walking from {from} to {to} visited more than {MAX_VISITED} packages; is {LOCKFILE} damaged?"); + } + if node == to { + return Ok(Some(walk_back(&parents, node))); + } + let Some(next) = graph.get(&node) else { continue }; + for dependency in next { + if dependency != from && !parents.contains_key(dependency.as_str()) { + parents.insert(dependency.clone(), node.clone()); + queue.push_back(dependency.clone()); + } + } + } + Ok(None) +} + +/// The chain from the start of the walk to `node`, following the discovery parents. +fn walk_back(parents: &BTreeMap, node: String) -> Vec { + let mut chain = vec![node.clone()]; + let mut current = node; + while let Some(parent) = parents.get(¤t) { + chain.push(parent.clone()); + current = parent.clone(); + } + chain.reverse(); + chain +} + +/// One row per package and network-capable crate. +fn rows(graph: &BTreeMap>) -> anyhow::Result> { + let mut rows = Vec::with_capacity(PACKAGES.len().saturating_mul(NETWORK_CRATES.len())); + for package in PACKAGES { + for name in NETWORK_CRATES { + let path = shortest_path(graph, package, name)?; + let path = path.map(|chain| chain.join(" -> ")); + let reachable = path.is_some(); + let row = Row { package: (*package).to_owned(), name: (*name).to_owned(), reachable, path: path.unwrap_or_default() }; + rows.push(row); + } + } + Ok(rows) +} + +/// Write the reviewed baseline: the reachable pairs only, so a reader sees exactly the accepted +/// answers and nothing else. +fn write_baseline(path: &Path, rows: &[Row]) -> anyhow::Result<()> { + let reachable: Vec<&Row> = rows.iter().filter(|r| r.reachable).collect(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + } + let text = serde_json::to_string_pretty(&reachable)? + "\n"; + std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?; + println!("baseline updated: {} rows in {BASELINE}", reachable.len()); + Ok(()) +} + +fn read_baseline(path: &Path) -> anyhow::Result> { + let text = + std::fs::read_to_string(path).with_context(|| format!("{} is missing: run `cargo xtask netcheck --update-baseline`", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display())) +} + +/// The reachable pairs of a row list, for set comparison. +fn pairs(rows: &[Row]) -> BTreeSet<(&str, &str)> { + rows.iter().filter(|r| r.reachable).map(|r| (r.package.as_str(), r.name.as_str())).collect() +} + +/// Compare the current answers with the reviewed baseline: a reachable pair the baseline does not +/// list fails, and a pair that stopped being reachable or moved to another path is reported for +/// the next baseline update. +fn check_rows(rows: &[Row], reviewed: &[Row]) -> anyhow::Result<()> { + let listed = pairs(reviewed); + let found = pairs(rows); + for row in rows.iter().filter(|r| r.reachable) { + if let Some(before) = reviewed.iter().find(|b| b.reachable && b.package == row.package && b.name == row.name) + && before.path != row.path + { + println!("path changed for {} -> {}: {} (was {})", row.package, row.name, row.path, before.path); + } + } + let gone: Vec = listed.difference(&found).map(|(package, name)| format!("{package} -> {name}")).collect(); + if !gone.is_empty() { + println!("no longer reachable (run --update-baseline to record it): {}", gone.join("; ")); + } + let new: Vec = found.difference(&listed).map(|(package, name)| format!("{package} -> {name}")).collect(); + if new.is_empty() { + println!("OK: all {} reachable pairs are in the reviewed baseline", found.len()); + return Ok(()); + } + for pair in &new { + println!(" - {pair}"); + } + bail!("{} reachable network crate pair(s) are not in {BASELINE}; review them, then run `cargo xtask netcheck --update-baseline`", new.len()) +} + +pub fn run(args: &[String]) -> anyhow::Result<()> { + let update = args.iter().any(|a| a == "--update-baseline"); + let lockfile = root().join(LOCKFILE); + let text = std::fs::read_to_string(&lockfile).with_context(|| format!("reading {}", lockfile.display()))?; + let graph = parse_lockfile(&text)?; + if let Some(package) = PACKAGES.iter().find(|name| !graph.contains_key(**name)) { + bail!("{package} is not in {LOCKFILE}; is it still a workspace member?"); + } + let rows = rows(&graph)?; + let reachable = rows.iter().filter(|r| r.reachable).count(); + println!("netcheck: {} packages x {} network-capable crates; {reachable} reachable", PACKAGES.len(), NETWORK_CRATES.len()); + for row in rows.iter().filter(|r| r.reachable) { + println!(" {} -> {}: {}", row.package, row.name, row.path); + } + let path = root().join(BASELINE); + let reviewed = if update { + write_baseline(&path, &rows)?; + rows.clone() + } else { + read_baseline(&path)? + }; + check_rows(&rows, &reviewed) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A lock file with a block list, an inline list, a versioned entry and a patch block. + const SAMPLE: &str = r#" +version = 4 + +[[package]] +name = "root" +version = "0.1.0" +dependencies = [ + "middle", + "serde 1.0.0", +] + +[[package]] +name = "middle" +version = "0.1.0" +source = "registry+https://example.invalid/index" +dependencies = [ + "leaf", +] + +[[package]] +name = "leaf" +version = "0.1.0" + +[[package]] +name = "empty" +version = "0.1.0" +dependencies = [] + +[[patch.unused]] +name = "spare" +version = "0.2.0" + +[[package]] +name = "tail" +version = "0.1.0" +dependencies = ["leaf"] +"#; + + #[test] + fn lockfile_dependency_lists_become_edges() { + let graph = parse_lockfile(SAMPLE).expect("the sample parses"); + assert_eq!(graph.get("root"), Some(&BTreeSet::from(["middle".to_string(), "serde".to_string()]))); + assert_eq!(graph.get("middle"), Some(&BTreeSet::from(["leaf".to_string()]))); + assert_eq!(graph.get("tail"), Some(&BTreeSet::from(["leaf".to_string()])), "an inline list is read too"); + assert_eq!(graph.get("empty"), Some(&BTreeSet::new())); + assert!(graph.contains_key("leaf"), "a package with no dependency list is still a node"); + assert!(graph.contains_key("spare"), "a patch block names a package too"); + } + + #[test] + fn shortest_path_names_the_chain_and_follows_dependencies_only() { + let graph = parse_lockfile(SAMPLE).expect("the sample parses"); + assert_eq!(shortest_path(&graph, "root", "leaf").unwrap(), Some(vec!["root".to_string(), "middle".to_string(), "leaf".to_string()])); + assert_eq!(shortest_path(&graph, "leaf", "root").unwrap(), None); + assert_eq!(shortest_path(&graph, "root", "nowhere").unwrap(), None); + assert_eq!(shortest_path(&graph, "root", "root").unwrap(), Some(vec!["root".to_string()])); + } + + #[test] + fn a_lockfile_without_packages_is_an_error() { + let error = parse_lockfile("version = 4\n").expect_err("an empty graph must not be read as an answer"); + assert!(error.to_string().contains("names no package"), "{error}"); + } + + #[test] + fn committed_baseline_accepts_the_reachable_pairs_of_the_lockfile_graph() { + let text = std::fs::read_to_string(root().join(LOCKFILE)).expect("Cargo.lock is committed"); + let graph = parse_lockfile(&text).expect("Cargo.lock parses"); + let found = rows(&graph).expect("the walk finishes"); + let reviewed = read_baseline(&root().join(BASELINE)).expect("the baseline is committed"); + // The gate's own rule: a pair that went away or moved is reported, not a failure. + check_rows(&found, &reviewed).expect("every reachable pair is in the baseline"); + } +} From 60f60fc0a2c3fd58f6679483cfae1d2478e673dd Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 22:25:30 +0200 Subject: [PATCH 31/43] Give a redacted or sanitized copy a new file identifier (#3734) * cos: give a redacted or sanitized full save a new file identifier A full save that the document asked for (Document::require_full_save_with_new_id) now writes a fresh /ID pair, so a redacted or sanitized copy does not claim to be the file it came from (ISO 32000-2 section 14.4). Ordinary saves are unchanged. An encrypted file keeps the first string of /ID, because the file key is derived from it, and gets a new second string. An encrypted file whose /ID is not a pair of strings is left as it was, as before. SaveOptions::id_entropy lets a caller or a test supply entropy; with it the new identifier depends only on that entropy and the written bytes. Without it the identifier mixes the standard library's random hash keys and a process-wide counter, the same facility the engine uses for new encryption keys. Checked: the new tests in crates/cos/tests/file_identifier.rs fail on the clean base (compile errors for the missing API), fail on assertions when only the API is in place, and fail again when each pinned line is reverted (the regeneration call, the first-string keep, the flag, the entropy input, and the ordinary-save guard). cargo test -p pdfcraft-cos passes, and clippy with warnings denied is clean. A probe that wrote 16 ordinary full and incremental saves gave byte-identical files with and without this change. * redact: give a redacted or sanitized copy a new file identifier Applying redactions, sanitizing and removing hidden information now ask the cos layer for a new file identifier (Document::require_full_save_with_new_id), so the saved copy no longer carries the /ID of its source. Before this change the previous revision was dropped but the identifier still linked the copy to the original. The request sits in remove_hidden itself, because its per-category callers (the remove-hidden edit and the optimizer's discard option) call it directly, not through sanitize(). sanitize() asks only on its nothing-hidden path, where remove_hidden returned before asking: the rewrite is a new file either way. Checked: the new tests a_redacted_or_sanitized_copy_gets_a_new_identifier and a_remove_hidden_copy_gets_a_new_identifier fail before this change (the request is missing after apply, and after remove_hidden) and fail again when the apply call, the nothing-hidden branch or the remove_hidden request is reverted. cargo test -p pdfcraft-redact passes with the tests included (152 passed, 1 ignored); cargo clippy with warnings denied and cargo fmt --check are clean. * docs(cos): say when a full save regenerates the file identifier The writing section of the crate README described full saves without saying what happens to /ID. It now says that an ordinary save keeps the identifier, that a document which asks for a new one (redaction and sanitizing) gets both strings regenerated, that SaveOptions::id_entropy supplies entropy for reproducible output, and that an encrypted file keeps its first string because the key depends on it. Checked: the text matches the code added in e1f761cc (regenerate_id, new_id_bytes, require_full_save_with_new_id) and the tests in crates/cos/tests/file_identifier.rs. No code changed in this commit. * cos: hash the whole rewritten file into the new identifier The identifier of a requested rewrite hashed only the first 64 KiB of the written file. With caller-supplied id_entropy the identifier is deterministic, so two rewrites of documents with the same length that differ only after byte 65,536 received the same /ID, and a later edit of a large document could keep its previous /ID. Every written byte is now hashed. The output is read once: one loop over 4 KiB chunks feeds both halves of the 16-byte identifier, so the extra cost is one linear pass over the output. Measured with a probe in the workspace dev profile (opt-level 1): hashing 64 MiB took 0.024 s, about 24 ms per 64 MiB of output. A release build was not measured. Ordinary saves never reach this code, and their output is unchanged. Checked: crates/cos/tests/file_identifier.rs gains the_identifier_depends_on_the_whole_rewritten_file. It fails on the branch head before this change ("content past 64 KiB must change the identifier", both identifiers equal) and passes after it; reverting only the chunk loop to the 64 KiB prefix makes it fail again. The six existing tests in that file pass, and so do the full cos and redact test suites. * cos: keep the key valid when an encrypted rewrite has no usable /ID A requested rewrite of an encrypted file whose /ID was absent, had one entry, or had a first entry that is not a string wrote no identifier at all, although the document had asked for a new one. The reader takes the empty string as the key input for these shapes (Document::first_id), so the rewrite now writes the empty string (<>) as the first entry and a new second entry. The key stays valid. A file whose first entry is a string keeps that string, as before. Ordinary saves and unencrypted rewrites are unchanged. Checked: an_encrypted_rewrite_without_a_usable_identifier_still_gets_a_new_one in crates/cos/tests/file_identifier.rs covers the three shapes for RC4-128, AES-128 and AES-256. After the rewrite each file opens with its password, the first string is empty and the second is new. On the branch head the test fails with "the rewrite writes a pair" (left 0, right 2). Reverting only the guard to the head's early return makes it fail again. Isolated, the one-entry shape fails with left 1, right 2, and the non-string shape with left Some(0), right Some(16). The other seven tests in the file pass. * cos: derive the identifier from caller entropy with a fixed hash With SaveOptions::id_entropy the identifier was built with std's DefaultHasher, whose algorithm the standard library does not specify, and with write_usize (4 bytes on wasm32, 8 on 64-bit targets) and native-endian integers. The same entropy and the same file could therefore give different identifiers on different platforms or toolchains, although the documentation promised the bytes depend only on the entropy and the written file. The identifier is now FNV-1a (64 bit, the published constants) over fixed-width big-endian fields and the whole written file: the salt, the lane, id_seed and the length, then the bytes. With caller entropy the salt is the FNV-1a hash of that entropy. Without entropy it is still the standard library's random hash of a process counter, so only the salt differs between the two paths. Every written byte is hashed, in one pass that feeds both lanes. Cost: this pass is slower than the SipHash it replaces. A temporary probe in the workspace dev profile (opt-level 1) hashed 64 MiB through both lanes in 0.129 s (about 500 MiB/s), against 0.024 s before. A release build was not measured. The cost is paid once per requested rewrite; ordinary saves do not reach this code. Checked: the golden test fixed_entropy_rewrite in crates/cos/tests/file_identifier.rs pins the 16 bytes for a fixed entropy and the standard fixture. On the previous hash it fails (left [64, 145, ...], right [50, 125, ...]). An independent re-implementation of the same formula in Python, run over the bytes the writer hashed (everything before startxref), gives the same 16 bytes. The unit test fnv1a_is_the_published_64_bit_algorithm checks the constants against the published vectors. FNV is not collision-resistant against chosen content; that matters only if someone could choose a file's identifier, and the identifier only has to be unique. * Repair review branch integration * cos: take fresh identifier entropy from the browser on wasm Without SaveOptions::id_entropy the salt of a rewritten file's identifier was RandomState and a process-wide counter. On wasm32-unknown-unknown those hash keys are fixed at compile time, so two fresh web apps that followed the same allocation and call sequence reproduced the same salt and the same identifier for identical output, and a redacted copy stayed linkable to its source. The default salt now comes from the browser first: crypto.getRandomValues (Web Crypto, through js_sys, sixteen bytes folded with the same FNV-1a the identifier uses), or Math.random where that API is missing, and only when neither is reachable the previous RandomState salt. js-sys and wasm-bindgen join pdfcraft-cos as wasm32-only dependencies; both are already in the dependency tree of the web shell, and native builds compile neither. Caller entropy is untouched: with id_entropy the identifier is still a pure function of that entropy, the seed and the written bytes. Checked: separate_fresh_processes_get_different_identifiers re-runs this test binary as two fresh child processes over the same fixture and fails when the default salt is made constant (so does the in-process divergence test); stand_in_for_a_fresh_process is its no-op child half. Both pass with the real salt. On WebAssembly no browser test harness exists, so the browser path is covered by the wasm32 cargo check and by construction, not by a test. cargo check --target wasm32-unknown-unknown -p pdfcraft-cos passes, cargo test -p pdfcraft-cos and -p pdfcraft-redact pass, and clippy with warnings denied is clean on both. * Extract full-save trailer assembly Assisted-by: OpenAI Codex --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- Cargo.lock | 2 + crates/cos/Cargo.toml | 7 + crates/cos/README.md | 8 + crates/cos/src/document.rs | 3 + crates/cos/src/document/open.rs | 15 ++ crates/cos/src/writer.rs | 229 ++++++++++++++++++++--- crates/cos/tests/file_identifier.rs | 279 ++++++++++++++++++++++++++++ crates/redact/src/lib.rs | 5 +- crates/redact/src/sanitize.rs | 11 +- crates/redact/src/tests.rs | 81 ++++++++ 10 files changed, 614 insertions(+), 26 deletions(-) create mode 100644 crates/cos/tests/file_identifier.rs diff --git a/Cargo.lock b/Cargo.lock index 1bf064f5d2..e68f422a52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4333,6 +4333,7 @@ version = "0.18.1" dependencies = [ "chrono", "hayro-syntax", + "js-sys", "pdfcraft-crypt", "pdfcraft-filters", "pdfcraft-oracle", @@ -4340,6 +4341,7 @@ dependencies = [ "proptest", "serde_json", "thiserror 2.0.21", + "wasm-bindgen", ] [[package]] diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml index a2fb3f4c4e..0bf2e894d2 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -15,6 +15,13 @@ thiserror = { workspace = true } # PDF date strings (`pdf_date`) go through chrono (no hand-rolled calendar code). chrono = { workspace = true } +# On the web, a rewrite's fresh file identifier takes its entropy from the browser +# (`crypto.getRandomValues`). Both crates are already in the dependency tree of the web shell +# (`js-sys` 0.3 / `wasm-bindgen` 0.2); native builds compile neither. +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" +wasm-bindgen = "0.2" + [dev-dependencies] pdfcraft-oracle = { workspace = true } pdfcraft-testkit = { workspace = true } diff --git a/crates/cos/README.md b/crates/cos/README.md index 398b161625..774f2c4367 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -89,6 +89,14 @@ into compressed object streams with a cross-reference stream (PDF 1.5+); turning classic table any reader accepts. Both forms map accepted xref/header aliases to one output object, retaining shared identity such as page-tree parent links; unrelated generations stay null. +A full save keeps the file's `/ID` as it is, unless the document asked for a new identifier with +`Document::require_full_save_with_new_id` (redaction and sanitizing do). Then both strings are +regenerated, and `SaveOptions::id_entropy` can supply entropy for reproducible output (tests pin +output this way). Without it the identifier mixes in fresh entropy from the host — the browser's +`crypto.getRandomValues` on the web, the operating system's random hash seed on the desktop — so a +fresh copy of the program cannot reproduce it. An encrypted file keeps the first string, because its +key is derived from it. + Dictionaries keep their key order and streams keep their *encoded* bytes, so rewritten objects stay recognisable in a diff and byte-stable when re-serialized. diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index e52aba2ce3..e5ce531f67 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -71,6 +71,9 @@ pub struct Document { encryption_changed: bool, /// Set by edits that must not leave earlier revisions in the file (redaction). full_save: bool, + /// The next full save also gives the file a new identifier (`/ID`), see + /// [`Document::require_full_save_with_new_id`]. + new_id: bool, /// The handler and `/Encrypt` object number that saves use, when encryption changed. out_security: Option>, out_encrypt_num: Option, diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index 7a4337c8da..3bf6677e99 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -35,6 +35,7 @@ impl Document { unfound: Arc::default(), encryption_changed: false, full_save: false, + new_id: false, out_security: None, out_encrypt_num: None, }; @@ -121,6 +122,7 @@ impl Document { unfound: Arc::default(), encryption_changed: false, full_save: false, + new_id: false, out_security: None, out_encrypt_num: None, }; @@ -290,12 +292,25 @@ impl Document { self.full_save = true; } + /// Like [`Document::require_full_save`], and the rewritten file also gets a new file identifier + /// (`/ID`), so that a redacted or sanitized copy never claims to be its source (ISO 32000-2 + /// §14.4). An encrypted file keeps the first string of its identifier, which its key depends on. + pub fn require_full_save_with_new_id(&mut self) { + self.full_save = true; + self.new_id = true; + } + /// The next save must rewrite the whole file (see [`Document::require_full_save`]). /// Repair-aware edits retaining two accepted generations cannot use one incremental slot. pub fn full_save_required(&self) -> bool { self.full_save || self.overlay.values().any(|slot| matches!(slot, Slot::RepairedSet(..))) } + /// The next full save gives the file a new identifier (see [`Document::require_full_save_with_new_id`]). + pub fn new_id_required(&self) -> bool { + self.new_id + } + /// Security was set or removed since the document was opened (the next save applies it). pub fn encryption_changed(&self) -> bool { self.encryption_changed diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 6c4c71580a..d5be18d96c 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -167,11 +167,17 @@ pub struct SaveOptions { /// before adding it, with [`Document::free_unlinked_additions`]. Full saves always leave /// these out. pub drop_unlinked: bool, + /// Entropy for the file identifier of a rewrite that asks for a new one (see + /// [`Document::require_full_save_with_new_id`]). Given, the identifier depends only on this and + /// on the written bytes, so the same save gives the same file; tests use it to pin output. + /// Without it, fresh entropy from the host (the operating system on native targets, the + /// browser on WebAssembly) is mixed in. + pub id_entropy: Option<[u8; 16]>, } impl Default for SaveOptions { fn default() -> Self { - Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true, drop_unlinked: false } + Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true, drop_unlinked: false, id_entropy: None } } } @@ -383,26 +389,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro } } batch.flush(&reader.document, &mut next, &mut rows, &mut out)?; - let mut trailer = Dict::new(); - for key in [&b"Root"[..], b"Info", b"Encrypt"] { - if let Some(r) = trailer_in.reference(key).and_then(|r| map.get(&r)) { - trailer.set(key.to_vec(), Object::Ref(ObjRef::new(*r, 0))); - } - } - if let Some(encrypt @ Object::Dict(_)) = trailer_in.get(b"Encrypt") { - trailer.set(b"Encrypt".to_vec(), renumber(encrypt, &map)); - } - if let Some(id) = trailer_in.get(b"ID") { - trailer.set(b"ID".to_vec(), direct_id(&reader.document, id)); - } - for (key, value) in trailer_in.iter() { - if !trailer_key_rewritten(key) { - trailer.set(key.clone(), renumber(value, &map)); - } - } - if reader.document.output_security().0.is_none() { - ensure_id(&mut trailer, opts, reader.document.bytes()); - } + let mut trailer = full_save_trailer(&reader.document, &trailer_in, &map, opts, &out); if opts.object_streams { write_xref_stream(&mut out, 0, &mut trailer, rows, next); } else { @@ -421,6 +408,34 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro Ok(out) } +/// Assemble the full rewrite's trailer and apply its identifier policy. +fn full_save_trailer(doc: &Document, trailer_in: &Dict, map: &HashMap, opts: &SaveOptions, out: &[u8]) -> Dict { + let mut trailer = Dict::new(); + for key in [&b"Root"[..], b"Info", b"Encrypt"] { + if let Some(r) = trailer_in.reference(key).and_then(|r| map.get(&r)) { + trailer.set(key.to_vec(), Object::Ref(ObjRef::new(*r, 0))); + } + } + if let Some(encrypt @ Object::Dict(_)) = trailer_in.get(b"Encrypt") { + trailer.set(b"Encrypt".to_vec(), renumber(encrypt, map)); + } + if let Some(id) = trailer_in.get(b"ID") { + trailer.set(b"ID".to_vec(), direct_id(doc, id)); + } + for (key, value) in trailer_in.iter() { + if !trailer_key_rewritten(key) { + trailer.set(key.clone(), renumber(value, map)); + } + } + if doc.output_security().0.is_none() { + ensure_id(&mut trailer, opts, doc.bytes()); + } + if doc.new_id_required() { + regenerate_id(&mut trailer, opts, out, doc.output_security().0.is_some()); + } + trailer +} + /// Object `r` read again for writing. The traversal read it once and only kept its number, so /// a second read that fails, finds nothing or finds a different kind of object must fail the /// save rather than write `null` in its place. @@ -669,6 +684,136 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s])); } +/// Give a rewritten file a new identifier (§14.4), so that a redacted or sanitized copy does not +/// claim to be its source. An encrypted file keeps the first string, which its key is derived from, +/// and gets a new second one. When there is no such string (no `/ID`, one entry, or a first entry +/// that is not a string) the reader takes the empty string as the key input (`Document::first_id`), +/// so the empty string is written first and the key stays valid. +fn regenerate_id(trailer: &mut Dict, opts: &SaveOptions, written: &[u8], encrypted: bool) { + let fresh = Object::String(PdfString { bytes: new_id_bytes(opts, written), hex: true }); + let pair = if encrypted { + let first = match trailer.get(b"ID") { + Some(Object::Array(a)) => match a.first() { + Some(Object::String(s)) => Object::String(s.clone()), + _ => Object::String(PdfString { bytes: Vec::new(), hex: true }), + }, + _ => Object::String(PdfString { bytes: Vec::new(), hex: true }), + }; + vec![first, fresh] + } else { + vec![fresh.clone(), fresh] + }; + trailer.set(b"ID".to_vec(), Object::Array(pair)); +} + +/// FNV-1a over 64 bits, with its published offset basis and prime. The identifier is built from it +/// rather than from `std`'s `DefaultHasher`, whose algorithm the standard library does not specify. +const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +fn fnv1a(state: u64, bytes: &[u8]) -> u64 { + bytes.iter().fold(state, |h, b| (h ^ u64::from(*b)).wrapping_mul(FNV_PRIME)) +} + +/// Sixteen bytes for a file identifier, one 8-byte half per lane. The bytes are a pure function of +/// `SaveOptions::id_entropy`, `id_seed` and the written file: the hash is FNV-1a (above) and every +/// integer is written as a fixed-width big-endian value, so the same input gives the same identifier +/// on every platform and toolchain. Without entropy only the salt changes (see `id_salt`). FNV is +/// not collision-resistant against chosen content; the identifier only has to be unique, not secret. +/// Every written byte is hashed, once, in a single traversal of the output. +fn new_id_bytes(opts: &SaveOptions, written: &[u8]) -> Vec { + let salt = id_salt(opts); + let length = u64::try_from(written.len()).unwrap_or(u64::MAX); + let mut halves = [0u8, 1].map(|lane| { + let mut h = fnv1a(FNV_OFFSET, &salt.to_be_bytes()); + h = fnv1a(h, &[lane]); + h = fnv1a(h, &opts.id_seed.to_be_bytes()); + fnv1a(h, &length.to_be_bytes()) + }); + for chunk in written.chunks(4096) { + for h in &mut halves { + *h = fnv1a(*h, chunk); + } + } + halves.iter().flat_map(|h| h.to_be_bytes()).collect() +} + +/// The salt of an identifier. With caller entropy it is a fixed hash of that entropy (deterministic, +/// for tests and callers who pin output). Without it the salt is fresh: on WebAssembly from the +/// browser ([`browser_salt`], so two fresh web apps that write the same bytes cannot share an +/// identifier), and elsewhere from the standard library's random hash keys — seeded by the operating +/// system, so separate processes differ — mixed with a process-wide counter, which separates +/// successive calls in one process. +fn id_salt(opts: &SaveOptions) -> u64 { + use std::hash::BuildHasher; + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + match &opts.id_entropy { + Some(entropy) => fnv1a(FNV_OFFSET, entropy), + None => { + #[cfg(target_arch = "wasm32")] + if let Some(salt) = browser_salt() { + return salt; + } + std::collections::hash_map::RandomState::new().hash_one(COUNTER.fetch_add(1, Ordering::Relaxed)) + } + } +} + +/// One byte from one `Math.random` draw: the draw (in [0, 1)) scaled to [0, 256), integer part +/// taken. The quotient is in [0, 256), so a saturating cast never wraps; a NaN or infinite draw +/// (which a conforming engine never returns) folds in as 0. No input can panic here. +#[cfg(any(target_arch = "wasm32", test))] +fn draw_byte(random: f64) -> u8 { + (random * 256.0).floor().rem_euclid(256.0) as u8 +} + +/// The `Math.random` fallback's mixing: one salt byte per draw (see [`draw_byte`]), folded through +/// FNV-1a exactly like the Web Crypto bytes, so all sixteen bytes of the fold carry entropy from +/// the draws. Pure and platform-independent, so the native test target can exercise it; on wasm +/// each draw comes from `Math.random`. +#[cfg(any(target_arch = "wasm32", test))] +fn salt_from_draws(draws: &[f64; 16]) -> u64 { + let mut bytes = [0u8; 16]; + for (byte, draw) in bytes.iter_mut().zip(draws) { + *byte = draw_byte(*draw); + } + fnv1a(FNV_OFFSET, &bytes) +} + +/// Fresh salt entropy from the browser, on WebAssembly. `crypto.getRandomValues` (Web Crypto, the +/// browser's secure generator, available in windows and workers alike) gives sixteen bytes, folded +/// into one salt. When that API is missing or refuses the call, `Math.random` is mixed instead +/// ([`salt_from_draws`]: sixteen draws, so the salt's full width carries entropy) — browsers seed +/// it afresh for every document, so a fresh instance still cannot reproduce another's salt. Only +/// where neither is reachable does the caller fall back to its `RandomState` salt, whose keys are +/// fixed at compile time on `wasm32-unknown-unknown` — the state before browser entropy was wired +/// in. +#[cfg(target_arch = "wasm32")] +fn browser_salt() -> Option { + use wasm_bindgen::JsCast; + let crypto = js_sys::Reflect::get(&js_sys::global(), &wasm_bindgen::JsValue::from_str("crypto")) + .ok() + .filter(|crypto| !crypto.is_undefined() && !crypto.is_null()); + if let Some(crypto) = crypto { + let fill = js_sys::Reflect::get(&crypto, &wasm_bindgen::JsValue::from_str("getRandomValues")) + .ok() + .and_then(|fill| fill.dyn_into::().ok()); + if let Some(fill) = fill { + let bytes = js_sys::Uint8Array::new_with_length(16); + if fill.call1(&crypto, &bytes).is_ok() { + return Some(fnv1a(FNV_OFFSET, &bytes.to_vec())); + } + } + } + // Web Crypto is missing or refused the call: degrade to `Math.random` rather than giving up. + let mut draws = [0.0; 16]; + for draw in &mut draws { + *draw = js_sys::Math::random(); + } + Some(salt_from_draws(&draws)) +} + /// A PDF date string for "now" given seconds since the Unix epoch (UTC). pub fn pdf_date(unix_secs: i64) -> String { let (days, secs) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400)); @@ -711,6 +856,48 @@ mod tests { use proptest::prelude::*; + #[test] + fn fnv1a_is_the_published_64_bit_algorithm() { + // Published FNV-1a (64 bit) test vectors, so the identifier can be reproduced outside this crate. + assert_eq!(super::fnv1a(super::FNV_OFFSET, b""), 0xcbf2_9ce4_8422_2325); + assert_eq!(super::fnv1a(super::FNV_OFFSET, b"a"), 0xaf63_dc4c_8601_ec8c); + assert_eq!(super::fnv1a(super::FNV_OFFSET, b"foobar"), 0x8594_4171_f739_67e8); + } + + #[test] + fn math_random_draws_fill_the_whole_salt_width() { + // Draw i = (i + 1/2)/256 scales to the integer i, so the fold's byte i proves draw i landed + // there: all sixteen bytes carry their own draw's entropy (the fallback once shifted four + // draws into one word, leaving the other four bytes of the fold constant). + let draws: [f64; 16] = std::array::from_fn(|i| (i as f64 + 0.5) / 256.0); + let bytes: Vec = (0..16).collect(); + assert_eq!(super::salt_from_draws(&draws), super::fnv1a(super::FNV_OFFSET, &bytes)); + } + + #[test] + fn every_math_random_draw_reaches_the_salt() { + let base = [0.5; 16]; + let baseline = super::salt_from_draws(&base); + for i in 0..16 { + let mut draws = base; + draws[i] = 0.75; + assert_ne!(super::salt_from_draws(&draws), baseline, "draw {i} did not reach the salt"); + } + } + + #[test] + fn a_math_random_draw_outside_its_range_still_mixes_without_panicking() { + // A conforming `Math.random` stays in [0, 1), but the mixing must not panic outside it. + assert_eq!(super::draw_byte(0.0), 0); + assert_eq!(super::draw_byte(0.5), 128); + assert_eq!(super::draw_byte(255.5 / 256.0), 255); + assert_eq!(super::draw_byte(-0.5 / 256.0), 255); + assert_eq!(super::draw_byte(-1.0), 0); + assert_eq!(super::draw_byte(2.0), 0); + assert_eq!(super::draw_byte(f64::NAN), 0); + assert_eq!(super::draw_byte(f64::INFINITY), 0); + } + use super::*; use crate::Lexer; diff --git a/crates/cos/tests/file_identifier.rs b/crates/cos/tests/file_identifier.rs new file mode 100644 index 0000000000..4612df54f0 --- /dev/null +++ b/crates/cos/tests/file_identifier.rs @@ -0,0 +1,279 @@ +//! The file identifier (`/ID`, ISO 32000-2 §14.4) of a full save. An ordinary save keeps the +//! identifier the file has and writes the same bytes twice. A rewrite the document asked for (after +//! redaction or sanitizing) gives the file a new pair. An encrypted file keeps its first string, +//! because its file key is derived from it. + +use std::sync::Arc; + +use pdfcraft_cos::{Algorithm, Document, NewEncryption, Object, PdfString, SaveOptions, write_full, write_incremental}; +use pdfcraft_testkit::Pdf; + +const ID_HEX: &str = "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]"; +const ID_BYTES: [u8; 16] = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; + +/// One page whose content holds a secret, with `entry` added to the trailer (for example an `/ID`). +fn source_with(entry: Option<&str>) -> Vec { + let pdf = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>") + .stream("", "BT /F1 12 Tf 20 100 Td (TOPSECRET) Tj ET") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + match entry { + Some(entry) => pdf.trailer_entry(entry).build(), + None => pdf.build(), + } +} + +/// [`source_with`] with the standard identifier, or none. +fn source(with_id: bool) -> Vec { + source_with(with_id.then_some(ID_HEX)) +} + +/// A one-page file whose 70,000-byte content stream ends in `last`. Two of these built with +/// different last bytes have the same length and differ only past the first 64 KiB. +fn large_source(last: u8) -> Vec { + let mut content = vec![b' '; 70_000]; + content.push(last); + Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>") + .stream("", content) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .build() +} + +fn reopen(bytes: Vec) -> Document { + Document::open(Arc::new(bytes)).unwrap() +} + +/// The strings of the trailer's `/ID` (empty when there is none). +fn id_strings(doc: &Document) -> Vec> { + match doc.trailer().get(b"ID") { + Some(Object::Array(a)) => a.iter().map(|s| s.as_string().map(|s| s.bytes.clone()).unwrap_or_default()).collect(), + _ => Vec::new(), + } +} + +#[test] +fn an_ordinary_save_keeps_the_identifier_and_writes_the_same_bytes() { + let doc = reopen(source(true)); + let opts = SaveOptions::default(); + let saved = write_full(&doc, &opts).unwrap(); + assert_eq!(saved, write_full(&doc, &opts).unwrap(), "unchanged inputs produce identical bytes"); + assert_eq!(id_strings(&reopen(saved)), vec![ID_BYTES.to_vec(), ID_BYTES.to_vec()]); + let appended = reopen(write_incremental(&doc, &opts).unwrap()); + assert_eq!(id_strings(&appended), vec![ID_BYTES.to_vec(), ID_BYTES.to_vec()]); +} + +#[test] +fn an_ordinary_save_of_a_file_without_an_identifier_creates_the_same_one_every_time() { + let doc = reopen(source(false)); + let opts = SaveOptions::default(); + let saved = write_full(&doc, &opts).unwrap(); + assert_eq!(saved, write_full(&doc, &opts).unwrap(), "unchanged inputs produce identical bytes"); + let ids = id_strings(&reopen(saved)); + assert_eq!(ids.len(), 2); + assert!(ids.iter().all(|s| s.len() == 16), "{ids:?}"); +} + +#[test] +fn a_rewrite_the_document_asked_for_gets_a_new_identifier() { + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + assert!(doc.full_save_required() && doc.new_id_required()); + let opts = SaveOptions::default(); + for saved in [write_incremental(&doc, &opts).unwrap(), write_full(&doc, &opts).unwrap()] { + let ids = id_strings(&reopen(saved)); + assert_eq!(ids.len(), 2); + for id in &ids { + assert_eq!(id.len(), 16); + assert_ne!(id.as_slice(), ID_BYTES.as_slice(), "the copy must not claim the source's identifier"); + } + } + // Every save is a new identity, not the same one again. + let first = id_strings(&reopen(write_full(&doc, &opts).unwrap())); + let second = id_strings(&reopen(write_full(&doc, &opts).unwrap())); + assert_ne!(first, second); +} + +#[test] +fn a_rewrite_without_an_identifier_still_gets_one() { + let mut doc = reopen(source(false)); + doc.require_full_save_with_new_id(); + let ids = id_strings(&reopen(write_full(&doc, &SaveOptions::default()).unwrap())); + assert_eq!(ids.len(), 2); + assert!(ids.iter().all(|s| s.len() == 16), "{ids:?}"); +} + +#[test] +fn caller_entropy_makes_the_new_identifier_reproducible() { + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + let with = |entropy: u8| SaveOptions { id_entropy: Some([entropy; 16]), ..SaveOptions::default() }; + let first = write_full(&doc, &with(1)).unwrap(); + assert_eq!(first, write_full(&doc, &with(1)).unwrap(), "the same entropy gives the same bytes"); + let other = write_full(&doc, &with(2)).unwrap(); + assert_ne!(id_strings(&reopen(first)), id_strings(&reopen(other)), "different entropy gives a different identifier"); +} + +#[test] +fn an_encrypted_rewrite_keeps_the_first_identifier_string_its_key_depends_on() { + for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] { + let mut doc = reopen(source(true)); + doc.set_encryption(&NewEncryption { + algorithm, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [5; 32], + }) + .unwrap(); + let protected = write_full(&doc, &SaveOptions::default()).unwrap(); + let before = Document::open_with_password(Arc::new(protected), Some("user")).unwrap(); + + let mut doc = before.clone(); + doc.require_full_save_with_new_id(); + let out = write_incremental(&doc, &SaveOptions::default()).unwrap(); + // The output opens with the same password only if its first identifier string is unchanged. + let back = Document::open_with_password(Arc::new(out), Some("user")).unwrap(); + let (old, new) = (id_strings(&before), id_strings(&back)); + assert_eq!(new.len(), 2, "{algorithm:?}"); + assert_eq!(new.first(), old.first(), "{algorithm:?}: the key depends on the first string"); + assert_ne!(new.get(1), old.get(1), "{algorithm:?}: the second string is new"); + } +} + +/// The `/ID` shapes whose key the reader takes as the empty string: none at all, one entry, and a +/// first entry that is not a string. +fn unusable_ids() -> Vec<(&'static str, Option)> { + let empty = Object::String(PdfString::literal(Vec::new())); + vec![ + ("absent", None), + ("one entry", Some(Object::Array(vec![empty.clone()]))), + ("first entry not a string", Some(Object::Array(vec![Object::Int(7), empty]))), + ] +} + +/// An encrypted file whose key is derived from the empty string, with its trailer `/ID` replaced by `id`. +fn protected_with_id(algorithm: Algorithm, id: Option) -> Vec { + let mut doc = reopen(source_with(Some("/ID [<> <>]"))); + doc.set_encryption(&NewEncryption { + algorithm, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [5; 32], + }) + .unwrap(); + match id { + Some(id) => doc.trailer_mut().set(b"ID".to_vec(), id), + None => { + doc.trailer_mut().remove(b"ID"); + } + } + write_full(&doc, &SaveOptions::default()).unwrap() +} + +#[test] +fn an_encrypted_rewrite_without_a_usable_identifier_still_gets_a_new_one() { + // The key is derived from the first string of /ID, and a missing /ID, a one-entry /ID and a + // first entry that is not a string all count as the empty string. The rewrite keeps that + // (empty) first string, so the file still opens with its password, and writes a new second one. + for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] { + for (shape, id) in unusable_ids() { + let before = Document::open_with_password(Arc::new(protected_with_id(algorithm, id)), Some("user")).unwrap(); + let mut doc = before.clone(); + doc.require_full_save_with_new_id(); + let out = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open_with_password(Arc::new(out), Some("user")).unwrap(); + let ids = id_strings(&back); + let previous = id_strings(&before); + assert_eq!(ids.len(), 2, "{algorithm:?}, {shape}: the rewrite writes a pair"); + assert!(ids.first().is_some_and(|s| s.is_empty()), "{algorithm:?}, {shape}: the first string is the one the key uses"); + assert_eq!(ids.get(1).map(Vec::len), Some(16), "{algorithm:?}, {shape}"); + assert_ne!(ids.get(1), previous.get(1), "{algorithm:?}, {shape}: the second string is new"); + } + } +} + +/// The identifier of [`fixed_entropy_rewrite`]. It was computed once with the FNV-1a formula in the +/// writer; it must not change with the platform or the toolchain. +const GOLDEN_FIXED_ENTROPY_ID: [u8; 16] = [0x32, 0x7d, 0x91, 0x46, 0xb8, 0x6d, 0xb3, 0xe1, 0x97, 0xc3, 0x5e, 0xdc, 0x58, 0x48, 0x3f, 0xba]; + +/// The standard fixture rewritten for a new identifier, with `id_entropy` of 0x42. +fn fixed_entropy_rewrite() -> Vec { + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + write_full(&doc, &SaveOptions { id_entropy: Some([0x42; 16]), ..SaveOptions::default() }).unwrap() +} + +#[test] +fn a_fixed_entropy_gives_the_same_identifier_on_every_platform() { + // The identifier is FNV-1a over fixed-width big-endian fields and the written file, so the same + // entropy and bytes give the same 16 bytes on every platform and toolchain. The value also pins + // this writer's output for the fixture. + let golden = GOLDEN_FIXED_ENTROPY_ID.to_vec(); + assert_eq!(id_strings(&reopen(fixed_entropy_rewrite())), vec![golden.clone(), golden]); +} + +#[test] +fn the_identifier_depends_on_the_whole_rewritten_file() { + // Same caller entropy and same length; the two rewrites differ only in one byte of a stream + // that lies past the first 64 KiB of the output. Their identifiers must still differ. + let opts = SaveOptions { id_entropy: Some([9; 16]), ..SaveOptions::default() }; + let rewrite = |last: u8| { + let mut doc = reopen(large_source(last)); + doc.require_full_save_with_new_id(); + write_full(&doc, &opts).unwrap() + }; + let (a, b) = (rewrite(b'A'), rewrite(b'B')); + assert_eq!(a.len(), b.len(), "the two rewrites have the same length"); + let first_difference = a.iter().zip(&b).position(|(x, y)| x != y).unwrap_or(usize::MAX); + assert!(first_difference > 64 * 1024, "the rewrites first differ at byte {first_difference}, inside the first 64 KiB"); + assert_ne!(id_strings(&reopen(a)), id_strings(&reopen(b)), "content past 64 KiB must change the identifier"); +} + +/// Where [`stand_in_for_a_fresh_process`] writes its rewrite. +const ID_PROBE: &str = "PDFCRAFT_TEST_ID_PROBE"; + +/// The child half of [`separate_fresh_processes_get_different_identifiers`]: it runs alone, in its +/// own process, and writes a default-path rewrite of the standard fixture to the file the parent +/// named. Without the variable it is a plain no-op. +#[test] +fn stand_in_for_a_fresh_process() { + let Some(path) = std::env::var_os(ID_PROBE) else { return }; + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + std::fs::write(path, write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(); +} + +#[test] +fn separate_fresh_processes_get_different_identifiers() { + // The default identifier is meant to differ between fresh instances of the whole save pipeline, + // not only between two calls in one process (which the counter already separates). On the native + // test target a fresh instance is a fresh process, whose hash keys the operating system seeds; + // the fix under test runs on WebAssembly, where no browser test harness exists — there the + // browser's `crypto.getRandomValues` stands in for this seeding, and this test cannot see it. + let exe = std::env::current_exe().unwrap(); + let probe = |name: &str| { + let path = std::env::temp_dir().join(format!("pdfcraft-id-probe-{}-{name}.pdf", std::process::id())); + let out = std::process::Command::new(&exe) + .args(["--exact", "stand_in_for_a_fresh_process", "--nocapture", "--"]) + .env(ID_PROBE, &path) + .output() + .expect("run this test's own binary as a child"); + assert!(out.status.success(), "{name}: the child failed: {}", String::from_utf8_lossy(&out.stderr)); + let saved = std::fs::read(&path).unwrap_or_else(|e| panic!("{name}: the child wrote its file: {e}")); + let ids = id_strings(&reopen(saved)); + assert_eq!(ids.len(), 2, "{name}"); + assert!(ids.iter().all(|s| s.len() == 16), "{name}: {ids:?}"); + let _ = std::fs::remove_file(&path); + ids + }; + let (first, second) = (probe("a"), probe("b")); + assert_ne!(first, second, "two fresh processes must not share an identifier"); +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 41ac47357e..b6a8885c03 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -554,8 +554,9 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co if residue > 0 { return Err(RedactError::Residue(residue)); } - // The previous revision still holds the removed content: the next save must rewrite. - doc.require_full_save(); + // The previous revision still holds the removed content: the next save must rewrite, and the + // rewritten file is a different file from its source, so it gets a new identifier. + doc.require_full_save_with_new_id(); Ok(report) } diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 6fd4354300..f8088e0bba 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -674,16 +674,21 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result