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.
This commit is contained in:
Helen Kwok committed 2026-11-05 17:08:14 +08:00
1 parent 0f99608112
commit d5b8a35248
4 files changed
+224 -68

No files matched your search

+2
View File
@@ -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.
+1 -1
View File
@@ -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"
+173 -67
View File
@@ -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::<web_sys::HtmlCanvasElement>().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<Vec<u8>, 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<web_sys::HtmlCanvasElement> {
use eframe::wasm_bindgen::JsCast;
web_sys::window()?.document()?.get_element_by_id("pdfcraft").and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().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<Vec<u8>, 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<Self> {
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::<dyn FnMut(web_sys::MessageEvent)>::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::<dyn FnMut(web_sys::MessageEvent)>::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::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
let Some(open) = event.dyn_ref::<web_sys::CustomEvent>() 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::<js_sys::Uint8Array>() {
array.to_vec()
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
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::<js_sys::Uint8Array>() {
array.to_vec()
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
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<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
@@ -254,9 +343,26 @@ mod host {
fn string(data: &JsValue, key: &str) -> Option<String> {
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");
}
}
}
+48
View File
@@ -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-<hash>.js"; // the wasm-bindgen glue shipped with the app
const canvas = document.getElementById("pdfcraft");
canvas.dataset.host = "page"; // or <canvas id="pdfcraft" data-host="page">
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-<hash>_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=<name>` 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