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/6] 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/6] 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/6] 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/6] 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/6] 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/6] 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.