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 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] =?UTF-8?q?M3:=20=E2=86=92=20/=20=E2=86=90=20scroll=20?= =?UTF-8?q?a=20page=20wider=20than=20the=20window=20before=20turning=20it?= =?UTF-8?q?=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 9/9] 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"