From a9e98e0db6fc428773711dd52c15e513fa592b9a Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:24:29 +0800 Subject: [PATCH 1/3] pdfcraft-web: window.pdfcraft, the page command API from craft-web-api, opt-in with ?control ?control attaches the UI control channel (the one --control serves) and installs window.pdfcraft: request, execute (ui.command), commands (ui.commands), docInspect (ui.state), uiInspect (ui.inspect) and screenshot. The channel keeps the accessibility tree of every frame, so it stays off unless the page asks. Opening, saving, the file name and the unsaved flag stay with the host messages. --- Cargo.lock | 15 +++- apps/pdfcraft-web/Cargo.toml | 5 +- apps/pdfcraft-web/src/api.rs | 127 ++++++++++++++++++++++++++++++++++ apps/pdfcraft-web/src/main.rs | 8 +++ packaging/web/README.md | 12 ++++ 5 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 apps/pdfcraft-web/src/api.rs diff --git a/Cargo.lock b/Cargo.lock index 8617c45522..0d315535a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1312,10 +1312,21 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-web-api" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" +dependencies = [ + "js-sys", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "craft-web-host" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6" +source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" dependencies = [ "wasm-bindgen", "web-sys", @@ -4754,11 +4765,13 @@ dependencies = [ name = "pdfcraft-web" version = "0.18.1" dependencies = [ + "craft-web-api", "craft-web-host", "eframe", "egui", "js-sys", "pdfcraft-ui-egui", + "serde_json", "wasm-bindgen-futures", "web-sys", ] diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index 56e10c0c29..ad5b62229c 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -12,9 +12,12 @@ 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" } +# The page command API (storytold/craft-libs#413): window.pdfcraft over the UI control channel. +craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } +craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] } egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } +serde_json = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" diff --git a/apps/pdfcraft-web/src/api.rs b/apps/pdfcraft-web/src/api.rs new file mode 100644 index 0000000000..379d092f72 --- /dev/null +++ b/apps/pdfcraft-web/src/api.rs @@ -0,0 +1,127 @@ +//! `window.pdfcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the app's +//! UI control channel (`pdfcraft_ui_egui::control`), opt-in with `?control`. +//! +//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, +//! `screenshot`, as promises with a timeout). This module is PdfCraft's part: a [`Request`] that +//! sends each call through the app's [`ControlClient`] and waits for the reply, and the table that +//! names PdfCraft's methods: its control channel drives the UI, so `commands` is `ui.commands`, +//! `execute` runs a registry command with `ui.command`, `docInspect` is `ui.state` and `uiInspect` +//! is `ui.inspect`. +//! +//! The channel keeps the accessibility tree of every frame, so it is off unless the page asks for it +//! (`?control`), as FigCraft's is. PdfCraft's control requests cannot be withdrawn, so a call the page +//! gave up on still runs when the app reaches it, as it does for a desktop client that timed out. + +use serde_json::{Value, json}; + +/// The reply of a control request as the protocol's `{ok, result}` / `{ok: false, error}` object. +fn reply_object(reply: Result) -> Value { + match reply { + Ok(result) => json!({"ok": true, "result": result}), + Err(error) => json!({"ok": false, "error": error}), + } +} + +/// The params of `ui.command`. `execute(command, params)` sends `{command, params}`; `ui.command` +/// takes `{id}` (and `params` for `ui.panel`). A request that already names an `id` is the page's own +/// `request("ui.command", {id})` and goes through unchanged. +fn command_params(params: Value) -> Value { + match params.get("command").and_then(Value::as_str) { + Some(command) if params.get("id").is_none() => json!({"id": command, "params": params.get("params").cloned().unwrap_or(Value::Null)}), + _ => params, + } +} + +#[cfg(target_arch = "wasm32")] +pub use web::install; + +#[cfg(target_arch = "wasm32")] +mod web { + use std::rc::Rc; + use std::sync::mpsc::{Receiver, TryRecvError}; + + use craft_web_api::methods::Methods; + use craft_web_api::page::{PageApi, Request}; + use pdfcraft_ui_egui::control::{ControlClient, Reply}; + use serde_json::{Value, json}; + use wasm_bindgen_futures::JsFuture; + + use super::{command_params, reply_object}; + + /// The app id in `window.`. + const APP: &str = "pdfcraft"; + + /// How often a waiting call looks for the app's reply, in milliseconds (about a frame). + const POLL_MS: i32 = 16; + + /// PdfCraft's control methods behind each function. + fn methods() -> Methods { + Methods { + commands: Some("ui.commands"), + execute: Some("ui.command"), + doc_inspect: Some("ui.state"), + ui_inspect: Some("ui.inspect"), + screenshot: Some("ui.screenshot"), + } + } + + /// Put `window.pdfcraft` on the page, sending its calls through `client`. An existing + /// `window.pdfcraft` keeps the functions it already has. + pub fn install(client: ControlClient) { + let request: Request = Rc::new(move |method, params| { + let params = if method == "ui.command" { command_params(params) } else { params }; + Box::pin(wait(client.send(&method, params))) + }); + if let Err(e) = Rc::new(PageApi::new(APP, methods(), request)).install() { + eframe::web_sys::console::warn_1(&format!("PdfCraft: couldn't install window.{APP}: {e:?}").into()); + } + } + + /// The app's reply, polled about once a frame. + async fn wait(reply: Receiver) -> Value { + loop { + match reply.try_recv() { + Ok(reply) => return reply_object(reply), + Err(TryRecvError::Empty) => sleep(POLL_MS).await, + Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}), + } + } + } + + async fn sleep(ms: i32) { + let promise = js_sys::Promise::new(&mut |resolve, _| { + if let Some(window) = web_sys::window() { + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms); + } + }); + let _ = JsFuture::from(promise).await; + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn execute_becomes_a_ui_command_by_id() { + assert_eq!(command_params(json!({"command": "file.word_count", "params": {"a": 1}})), json!({"id": "file.word_count", "params": {"a": 1}})); + assert_eq!(command_params(json!({"command": "file.save", "params": {}})), json!({"id": "file.save", "params": {}})); + } + + #[test] + fn a_request_that_names_an_id_goes_through_unchanged() { + let own = json!({"id": "file.save"}); + assert_eq!(command_params(own.clone()), own); + let both = json!({"id": "ui.panel", "command": "x", "params": {"reset": true}}); + assert_eq!(command_params(both.clone()), both, "an id wins over a command"); + assert_eq!(command_params(json!({})), json!({})); + } + + #[test] + fn replies_become_the_protocols_objects() { + assert_eq!(reply_object(Ok(json!({"n": 1}))), json!({"ok": true, "result": {"n": 1}})); + assert_eq!(reply_object(Err("boom".to_string())), json!({"ok": false, "error": "boom"})); + } +} diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index ffa5a06068..d23dd15a57 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -3,6 +3,9 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(any(target_arch = "wasm32", test))] +mod api; + #[cfg(target_arch = "wasm32")] fn main() { use pdfcraft_ui_egui::PdfCraftApp; @@ -68,6 +71,11 @@ fn main() { &"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(), ); } + // `?control`: `window.pdfcraft`, the page's command API (`api`). Opt-in: the control + // channel keeps the accessibility tree of every frame. + if query_param("control").is_some() { + api::install(app.attach_control(&cc.egui_ctx)); + } Ok(Box::new(app)) }), ) diff --git a/packaging/web/README.md b/packaging/web/README.md index 3149dc75d5..a1fe64c952 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -136,6 +136,18 @@ The messages and both transports are the shared contract of [`craft-web-host`](h - **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. +### Driving it from the page (`window.pdfcraft`) + +`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`, `ui.open`…), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app still runs it when it reaches it, as it does for a desktop client that timed out. + +```js +const state = await pdfcraft.docInspect(); // open documents, page, zoom, panels, dialog +await pdfcraft.request("ui.set", { key: "show_page_edge_shadows", value: "false" }); +const { data } = await pdfcraft.screenshot(); // the window as PNG (base64) +``` + +Opening a PDF, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above. + ## Renderer selection and fallback flags PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to From 6ec23789c1a6ab072195cc6a69774d26f09667af Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:28:05 +0800 Subject: [PATCH 2/3] pdfcraft-web: say a timed-out call is not guaranteed to have been skipped --- apps/pdfcraft-web/src/api.rs | 2 +- packaging/web/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/pdfcraft-web/src/api.rs b/apps/pdfcraft-web/src/api.rs index 379d092f72..3f1d265377 100644 --- a/apps/pdfcraft-web/src/api.rs +++ b/apps/pdfcraft-web/src/api.rs @@ -10,7 +10,7 @@ //! //! The channel keeps the accessibility tree of every frame, so it is off unless the page asks for it //! (`?control`), as FigCraft's is. PdfCraft's control requests cannot be withdrawn, so a call the page -//! gave up on still runs when the app reaches it, as it does for a desktop client that timed out. +//! gave up on may still run (or be rejected) when the app reaches it, as it does for a desktop client that timed out. use serde_json::{Value, json}; diff --git a/packaging/web/README.md b/packaging/web/README.md index a1fe64c952..375f1f4ba0 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -138,7 +138,7 @@ The messages and both transports are the shared contract of [`craft-web-host`](h ### Driving it from the page (`window.pdfcraft`) -`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`, `ui.open`…), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app still runs it when it reaches it, as it does for a desktop client that timed out. +`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`, `ui.open`…), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app may still reach it later and run it (or reject it), as it does for a desktop client that timed out, so a timed-out call is not guaranteed to have been skipped. ```js const state = await pdfcraft.docInspect(); // open documents, page, zoom, panels, dialog From a9ccca5b71c70ffb20d1ea71e33e9bdea618ad49 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:30:00 +0800 Subject: [PATCH 3/3] pdfcraft-web: say ui.open answers with an error on the web --- packaging/web/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/web/README.md b/packaging/web/README.md index 375f1f4ba0..7d305b801d 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -138,7 +138,7 @@ The messages and both transports are the shared contract of [`craft-web-host`](h ### Driving it from the page (`window.pdfcraft`) -`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`, `ui.open`…), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app may still reach it later and run it (or reject it), as it does for a desktop client that timed out, so a timed-out call is not guaranteed to have been skipped. +`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`…; `ui.open` needs a file system, so on the web it answers with an error and a file is dropped on the page instead), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app may still reach it later and run it (or reject it), as it does for a desktop client that timed out, so a timed-out call is not guaranteed to have been skipped. ```js const state = await pdfcraft.docInspect(); // open documents, page, zoom, panels, dialog