diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 3b451f2..f05423e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (270) +## In this repository (262) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -223,14 +223,6 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) | | `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) | | `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. | -| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | -| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | -| `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | -| `docs/brand/artcraft-logo.svg` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | -| `docs/brand/artcraft-mark-black.png` | ArtCraft brand: artcraft-mark-black | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | -| `docs/brand/artcraft-mark-black.svg` | ArtCraft brand: artcraft-mark-black | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | -| `docs/brand/artcraft-mark.png` | ArtCraft brand: artcraft-mark | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | -| `docs/brand/artcraft-mark.svg` | ArtCraft brand: artcraft-mark | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `assets/app-icon/pdfcraft.svg` | PdfCraft app icon (master vector) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Master artwork; every other app-icon file is rendered from it (packaging/icons.sh) | | `assets/app-icon/pdfcraft-small.svg` | PdfCraft app icon (light vector) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | README header and assets/app-icon/README.md | | `assets/app-icon/pdfcraft-1024.png` | PdfCraft app icon, 1024 px on Apple's icon grid | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs) | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 07433fc..20001fb 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2710,94 +2710,6 @@ kind = "font" usage = "Showcase PDF (cargo xtask demo-pdf)" sha256 = "acd98e64795781b2058f07b18475e0ecee2a0fe2b42a49e2f9e37d0d6bf66ce6" -[[asset]] -path = "docs/brand/artcraft-logo-white.png" -title = "ArtCraft brand: artcraft-logo-white" -author = "ArtCraft Team" -source = "ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft)" -licence = "LicenseRef-Storyteller-Trademark" -licence_file = "docs/brand/LICENSE-brand.txt" -kind = "logo" -usage = "ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it." -sha256 = "8b86aeb06a12a90ba77a60e0d90329272310e244ee5c1f9b161b391280ca79d8" - -[[asset]] -path = "docs/brand/artcraft-logo-white.svg" -title = "ArtCraft brand: artcraft-logo-white" -author = "ArtCraft Team" -source = "ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft)" -licence = "LicenseRef-Storyteller-Trademark" -licence_file = "docs/brand/LICENSE-brand.txt" -kind = "logo" -usage = "ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it." -sha256 = "a3e03dc49f9ad4225b8517d5424309c8d1c864d3996d862190d9cc797d5af427" - -[[asset]] -path = "docs/brand/artcraft-logo.png" -title = "ArtCraft brand: artcraft-logo" -author = "ArtCraft Team" -source = "ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft)" -licence = "LicenseRef-Storyteller-Trademark" -licence_file = "docs/brand/LICENSE-brand.txt" -kind = "logo" -usage = "ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it." -sha256 = "08fc6d9d3717e8f13417bac6f9025b50e11d1e0b32e9e000883d2fe0c4a0c67b" - -[[asset]] -path = "docs/brand/artcraft-logo.svg" -title = "ArtCraft brand: artcraft-logo" -author = "ArtCraft Team" -source = "ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft)" -licence = "LicenseRef-Storyteller-Trademark" -licence_file = "docs/brand/LICENSE-brand.txt" -kind = "logo" -usage = "ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it." -sha256 = "3188971e1affdf4f6be4fa65f715376a7a305e6aa741ec78b0296c8f934eff08" - -[[asset]] -path = "docs/brand/artcraft-mark-black.png" -title = "ArtCraft brand: artcraft-mark-black" -author = "ArtCraft Team" -source = "ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft)" -licence = "LicenseRef-Storyteller-Trademark" -licence_file = "docs/brand/LICENSE-brand.txt" -kind = "logo" -usage = "ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it." -sha256 = "5954cc0306e1b9d7beb47097e2e842157113a39d9aba078b57304902687936a9" - -[[asset]] -path = "docs/brand/artcraft-mark-black.svg" -title = "ArtCraft brand: artcraft-mark-black" -author = "ArtCraft Team" -source = "ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft)" -licence = "LicenseRef-Storyteller-Trademark" -licence_file = "docs/brand/LICENSE-brand.txt" -kind = "logo" -usage = "ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it." -sha256 = "570adcb4f7ba8ca0647f97074476bab81d3272988f1cdee52efa21bf026036a4" - -[[asset]] -path = "docs/brand/artcraft-mark.png" -title = "ArtCraft brand: artcraft-mark" -author = "ArtCraft Team" -source = "ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft)" -licence = "LicenseRef-Storyteller-Trademark" -licence_file = "docs/brand/LICENSE-brand.txt" -kind = "logo" -usage = "ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it." -sha256 = "9d372157a949c147318561e629b11561c28b7cbc14c84b516a3fe6e84d8fbbad" - -[[asset]] -path = "docs/brand/artcraft-mark.svg" -title = "ArtCraft brand: artcraft-mark" -author = "ArtCraft Team" -source = "ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft)" -licence = "LicenseRef-Storyteller-Trademark" -licence_file = "docs/brand/LICENSE-brand.txt" -kind = "logo" -usage = "ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it." -sha256 = "f87af81b6fa411da8820eed2bfdcc54c0ee9cd453b4e868c64f56c69db142d0e" - [[asset]] path = "assets/app-icon/pdfcraft.svg" title = "PdfCraft app icon (master vector)" diff --git a/Cargo.lock b/Cargo.lock index f2acd78..513912b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4315,6 +4315,7 @@ dependencies = [ "egui", "js-sys", "pdfcraft-ui-egui", + "serde_json", "wasm-bindgen-futures", "web-sys", ] diff --git a/README.md b/README.md index b20179f..08dbe23 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@

- - - ArtCraft - + PdfCraft Online — community Agent build

diff --git a/ROADMAP.md b/ROADMAP.md index b4ab4ea..b4d4f42 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -445,3 +445,7 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-10-05 | major | Honest assessment and "Where we're lacking" added | - **2026-10-12 (M8):** Redaction cleans indirect and form structure references, preserving surviving form tags. + +## Community browser build — 2026-10-17 + +The pdfcraft.run fork adds an opt-in in-page Agent bridge to existing Session editing and undo, with document revision guards and bounded text/page tools. This is a site-maintained patch on v0.8.0; upstream milestone and parity ratings are unchanged. Verification evidence is maintained with the site patch and deployment log. diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index a64d729..90d0faf 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -18,3 +18,4 @@ egui = { workspace = true } wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] } js-sys = "0.3" +serde_json = { workspace = true } diff --git a/apps/pdfcraft-web/src/api.rs b/apps/pdfcraft-web/src/api.rs new file mode 100644 index 0000000..f02580e --- /dev/null +++ b/apps/pdfcraft-web/src/api.rs @@ -0,0 +1,116 @@ +//! Community web command bridge for pdfcraft.run (2026-10-17). +//! Uses the existing UI control queue; no network listener or file-upload service. +use eframe::wasm_bindgen::prelude::*; +use pdfcraft_ui_egui::control::{ControlClient, Reply}; +use serde_json::{Value, json}; +use std::cell::RefCell; +use std::sync::mpsc::{Receiver, TryRecvError}; + +type Waiting = (Receiver, js_sys::Function, js_sys::Function); +thread_local! { + static TX: RefCell> = const { RefCell::new(None) }; + static WAITING: RefCell> = const { RefCell::new(Vec::new()) }; + static CTX: RefCell> = const { RefCell::new(None) }; +} + +fn from_js(v: &JsValue) -> Result { + if v.is_null() || v.is_undefined() { + return Ok(json!({})); + } + let text = js_sys::JSON::stringify(v)?.as_string().ok_or_else(|| js_sys::Error::new("parameters must be JSON"))?; + if text.len() > 1_048_576 { + return Err(js_sys::Error::new("request exceeds 1 MiB").into()); + } + let value: Value = serde_json::from_str(&text).map_err(|e| js_sys::Error::new(&e.to_string()))?; + if !value.is_object() { + return Err(js_sys::Error::new("parameters must be an object").into()); + } + Ok(value) +} + +fn request(method: String, params: Value) -> js_sys::Promise { + if !matches!(method.as_str(), "agent.execute" | "agent.state" | "ui.command" | "ui.state" | "ui.set") { + return js_sys::Promise::reject(&js_sys::Error::new("unsupported web method")); + } + if WAITING.with(|w| w.borrow().len() >= 32) { + return js_sys::Promise::reject(&js_sys::Error::new("too many pending requests")); + } + let promise = js_sys::Promise::new(&mut |resolve, reject| { + if let Some(rx) = TX.with(|t| t.borrow().as_ref().map(|client| client.send(&method, params.clone()))) { + WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject))); + } else { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("PdfCraft is not ready")); + } + }); + CTX.with(|c| { + if let Some(ctx) = c.borrow().as_ref() { + ctx.request_repaint(); + } + }); + promise +} + +pub fn poll_replies() { + WAITING.with(|w| { + w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() { + Ok(reply) => { + match reply { + Ok(value) => match js_sys::JSON::parse(&value.to_string()) { + Ok(value) => { + let _ = resolve.call1(&JsValue::NULL, &value); + } + Err(error) => { + let _ = reject.call1(&JsValue::NULL, &error); + } + }, + Err(message) => { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&message)); + } + } + false + } + Err(TryRecvError::Empty) => true, + Err(TryRecvError::Disconnected) => { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("editor closed")); + false + } + }) + }); +} + +pub fn install(ctx: egui::Context, client: ControlClient) -> Result<(), String> { + TX.with(|t| *t.borrow_mut() = Some(client)); + CTX.with(|c| *c.borrow_mut() = Some(ctx)); + let obj = js_sys::Object::new(); + let set = |name: &str, value: JsValue| js_sys::Reflect::set(&obj, &name.into(), &value).map_err(|_| format!("could not install {name}")); + set("apiVersion", "craft-online-1".into())?; + set( + "request", + Closure:: js_sys::Promise>::new(|method: String, params: JsValue| match from_js(¶ms) { + Ok(p) => request(method, p), + Err(e) => js_sys::Promise::reject(&e), + }) + .into_js_value(), + )?; + set( + "execute", + Closure:: js_sys::Promise>::new(|command: String, params: JsValue, expected: JsValue| { + let parsed = from_js(¶ms).and_then(|p| { + let mut value = json!({"command": command, "params": p}); + if !expected.is_undefined() && !expected.is_null() { + value["expected"] = from_js(&expected)?; + } + Ok(value) + }); + match parsed { + Ok(p) => request("agent.execute".into(), p), + Err(e) => js_sys::Promise::reject(&e), + } + }) + .into_js_value(), + )?; + set("inspect", Closure:: js_sys::Promise>::new(|| request("agent.state".into(), json!({}))).into_js_value())?; + let win = web_sys::window().ok_or("no window")?; + js_sys::Reflect::set(&win, &"pdfcraft".into(), &obj).map_err(|_| "could not install window.pdfcraft")?; + Ok(()) +} diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index a46a467..62b86d8 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(target_arch = "wasm32")] +mod api; + #[cfg(target_arch = "wasm32")] fn main() { use eframe::wasm_bindgen::JsCast; @@ -71,7 +74,11 @@ fn main() { app.host_dirty = Some(bridge.dirty_fn()); bridge.post_ready(); } - Ok(Box::new(app)) + if query_param("agent").as_deref() == Some("1") && query_param("host").as_deref() == Some("parent") { + let client = app.attach_control(&cc.egui_ctx); + api::install(cc.egui_ctx.clone(), client)?; + } + Ok(Box::new(AgentShell(app))) }), ) .await; @@ -249,3 +256,22 @@ mod host { fn main() { eprintln!("pdfcraft-web targets wasm32: run `trunk serve` or `trunk build --release` in apps/pdfcraft-web"); } + +#[cfg(target_arch = "wasm32")] +struct AgentShell(pdfcraft_ui_egui::PdfCraftApp); +#[cfg(target_arch = "wasm32")] +impl eframe::App for AgentShell { + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + self.0.logic(ctx, frame); + api::poll_replies(); + } + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + self.0.ui(ui, frame); + } + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + self.0.raw_input_hook(ctx, raw); + } + fn save(&mut self, storage: &mut dyn eframe::Storage) { + self.0.save(storage); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 13233f7..614db0a 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -63,6 +63,11 @@ pub struct TextLine { } impl TextLine { + /// Visible paint-only text. Hidden OCR and clipping text are not browser Agent edit targets. + pub fn agent_visible(&self) -> bool { + matches!(self.origin.state.render_mode, 0..=2) + } + /// Origin of the source baseline in PDF user space, independent of font ascent/descent. pub fn baseline(&self) -> [f64; 2] { [self.origin.x, self.origin.baseline] diff --git a/crates/ui-egui/src/community_agent.rs b/crates/ui-egui/src/community_agent.rs new file mode 100644 index 0000000..43b471d --- /dev/null +++ b/crates/ui-egui/src/community_agent.rs @@ -0,0 +1,273 @@ +//! Community browser bridge. Every write reaches the real Session and native undo history. +use crate::PdfCraftApp; +use pdfcraft_engine::{AddedText, Edit}; +use serde_json::{Value, json}; + +fn integer(p: &Value, key: &str) -> Result { + p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| format!("{key} must be a non-negative integer")) +} +fn text<'a>(p: &'a Value, key: &str) -> Result<&'a str, String> { + p.get(key).and_then(Value::as_str).filter(|s| s.len() <= 8000).ok_or_else(|| format!("{key} must be text of at most 8000 UTF-8 bytes")) +} +fn finite(p: &Value, key: &str) -> Result { + p.get(key).and_then(Value::as_f64).filter(|n| n.is_finite()).ok_or_else(|| format!("{key} must be a finite number")) +} +impl PdfCraftApp { + fn agent_token(&self) -> Result { + if self.modal_open() { + return Err("Finish or cancel the open dialog before using the Agent.".into()); + } + if self.combine_showing() { + return Err("Select an open PDF tab first.".into()); + } + let (i, id) = self.active_ids().ok_or("Open a PDF first.")?; + let d = self.session.get(id).ok_or("PDF closed")?; + d.check_copying().map_err(|e| e.to_string())?; + let v = self.views.get(i).ok_or("PDF tab closed")?; + if v.pending_edit.is_some() + || v.content.draft.is_some() + || v.content.blocked().is_some() + || v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &d.form).is_some()) + || v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty()) + || v.line_editor.as_ref().is_some_and(|e| e.has_unsaved_text()) + || v.comments.editing.is_some() + || v.comments.composer.is_some() + { + return Err("Finish or cancel the current in-page edit before using the Agent.".into()); + } + Ok(json!({"uid":id.0.to_string(),"revision":d.edit_generation().to_string()})) + } + fn agent_state(&self, p: &Value) -> Result { + let token = self.agent_token()?; + let (i, id) = self.active_ids().ok_or("Open a PDF first.")?; + let d = self.session.get(id).ok_or("PDF closed")?; + let page = if p.get("page").is_some() { + integer(p, "page")?.checked_sub(1).ok_or("page numbers start at 1")? + } else { + self.views.get(i).map_or(0, |v| v.current) + }; + let info = d.info.pages.get(page).ok_or("page is outside the PDF")?; + let start = if p.get("start_line").is_some() { integer(p, "start_line")? } else { 0 }; + let count = if p.get("count").is_some() { integer(p, "count")? } else { 30 }; + if !(1..=40).contains(&count) { + return Err("count must be 1–40".into()); + } + let all = d.text_lines(page); + let mut remaining = 24000usize; + let lines: Vec = all + .iter() + .enumerate() + .skip(start) + .take(count) + .map(|(index, l)| { + let complete = l.text.len() <= 8000 && l.text.len() <= remaining; + let shown = if complete { + remaining -= l.text.len(); + l.text.clone() + } else { + String::new() + }; + json!({"index":index,"text":shown,"rect":l.rect,"size":l.size,"editable":d.allows_modification()&&!d.is_signed()&&l.decodable&&l.agent_visible()&&complete,"truncated":!complete}) + }) + .collect(); + Ok( + json!({"token":token,"document":{"pageCount":d.info.pages.len(),"editable":d.editable()&&!d.is_signed(),"signed":d.is_signed(),"canUndo":d.can_undo().is_some(),"canRedo":d.can_redo().is_some(),"dirty":d.dirty, + "page":{"number":page+1,"width":info.width,"height":info.height,"rotation":info.rotation,"totalLines":all.len(),"lines":lines}, + "warning":if d.edit_notices.is_empty(){Value::Null}else{json!("Some text may not render correctly in this PDF's fonts. Inspect the page and undo if needed.")}}}), + ) + } + pub(crate) fn community_agent(&mut self, method: &str, p: &Value) -> Result { + let token = self.agent_token()?; + if let Some(expected) = p.get("expected") + && expected != &token + { + return Err("document changed since inspection; start a new request".into()); + } + if method == "agent.state" { + return self.agent_state(p); + } + if p.get("expected").is_none() { + return Err("expected document state required".into()); + } + let (i, id) = self.active_ids().ok_or("Open a PDF first.")?; + let command = p.get("command").and_then(Value::as_str).ok_or("missing command")?; + let params = p.get("params").filter(|x| x.is_object()).ok_or("params must be an object")?; + let d = self.session.get(id).ok_or("PDF closed")?; + let mut page = self.views.get(i).map_or(0, |v| v.current); + if d.is_signed() { + return Err("Signed PDFs are read-only for this Agent.".into()); + } + if command == "undo" { + if d.can_undo().is_none() { + return Err("There is no edit to undo.".into()); + } + self.session.undo(id).map_err(|e| e.to_string())?; + } else { + if !d.editable() { + return Err("This PDF is read-only; its permissions must be respected.".into()); + } + let edit = match command { + "rotate_pages" => { + let input = params + .get("pages") + .and_then(Value::as_array) + .filter(|a| !a.is_empty() && a.len() <= 20) + .ok_or("pages must contain 1–20 page numbers")?; + let mut pages = Vec::new(); + for n in input { + let n = n + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .and_then(|n| n.checked_sub(1)) + .filter(|n| *n < d.info.pages.len()) + .ok_or("page is outside the PDF")?; + if pages.contains(&n) { + return Err("duplicate page number".into()); + } + pages.push(n); + } + let degrees = params + .get("degrees") + .and_then(Value::as_i64) + .filter(|n| matches!(n, -90 | 90 | 180)) + .ok_or("degrees must be -90, 90 or 180")?; + Edit::RotatePages { pages, degrees } + } + "replace_text_line" | "add_text" => { + page = integer(params, "page")?.checked_sub(1).ok_or("page numbers start at 1")?; + let info = d.info.pages.get(page).ok_or("page is outside the PDF")?; + let replacement = text(params, "text")?; + if command == "replace_text_line" { + let line = integer(params, "line")?; + let lines = d.text_lines(page); + let old = lines.get(line).ok_or("line is outside the page; read it first")?; + if !old.decodable || !old.agent_visible() || old.text.len() > 8000 { + return Err("This line cannot be safely edited through the Agent.".into()); + } + if text(params, "expected_text")? != old.text { + return Err("paragraph text changed; read the page again".into()); + } + if replacement == old.text || replacement.contains(['\n', '\r']) { + return Err("Use changed single-line text; paragraph reflow is not supported.".into()); + } + Edit::EditTextLine { page, line, text: replacement.into() } + } else { + let x = finite(params, "x")?; + let y = finite(params, "y")?; + let width = finite(params, "width")?; + let size = finite(params, "size")?; + if replacement.trim().is_empty() + || !(6.0..=72.0).contains(&size) + || x < 0.0 + || y < 0.0 + || width < 1.0 + || x + width > f64::from(info.width) + || y + size > f64::from(info.height) + { + return Err("Text box must fit on the page; size must be 6–72 points.".into()); + } + Edit::AddText { + page, + text: AddedText { rect: [x, y, x + width, y + size], text: replacement.into(), size, ..Default::default() }, + } + } + } + _ => return Err("unsupported Agent command".into()), + }; + self.session.apply(id, edit).map_err(|e| e.to_string())?; + } + // Refresh the canvas from the same working PDF bytes that Save writes. + if let (Some(v), Some(d)) = (self.views.get_mut(i), self.session.get(id)) { + v.document_changed(&d.info); + } + self.sync_unsaved_flag(); + Ok(json!({"result":true,"state":self.agent_state(&json!({"page":page+1}))?})) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> Vec { + let mut s = pdfcraft_engine::Session::new(); + let bytes = s.create_blank(200.0, 300.0, 2).unwrap(); + let id = s.open("fixture.pdf", None, bytes, None).unwrap(); + for (y, text) in [(20.0, "Original"), (70.0, "Keep this line")] { + s.apply(id, Edit::AddText { page: 0, text: AddedText { rect: [20.0, y, 180.0, y + 12.0], text: text.into(), ..Default::default() } }) + .unwrap(); + } + s.save_bytes(id).unwrap().as_ref().clone() + } + #[test] + fn live_pdf_edits_save_undo_and_stale_guards() { + let bytes = fixture(); + let mut app = PdfCraftApp::new(); + app.open_bytes("fixture.pdf", None, bytes.clone()).unwrap(); + let first = app.agent_state(&json!({"page":1})).unwrap(); + let line = first["document"]["page"]["lines"].as_array().unwrap().iter().find(|l| l["text"] == "Original").unwrap()["index"].clone(); + let params = json!({"command":"replace_text_line","params":{"page":1,"line":line,"expected_text":"Original","text":"Revised"},"expected":first["token"]}); + let changed = app.community_agent("agent.execute", ¶ms).unwrap(); + assert!(app.community_agent("agent.execute", ¶ms).unwrap_err().contains("document changed")); + let (_, id) = app.active_ids().unwrap(); + let saved = app.session.save_bytes(id).unwrap(); + let mut reopened = PdfCraftApp::new(); + reopened.open_bytes("saved.pdf", None, saved.as_ref().clone()).unwrap(); + let read = reopened.agent_state(&json!({"page":1})).unwrap().to_string(); + assert!(read.contains("Revised") && read.contains("Keep this line")); + let undone = app.community_agent("agent.execute", &json!({"command":"undo","params":{},"expected":changed["state"]["token"]})).unwrap(); + assert!(undone.to_string().contains("Original")); + assert_eq!(app.session.save_bytes(id).unwrap().as_ref(), &bytes); + let rotated = app + .community_agent( + "agent.execute", + &json!({"command":"rotate_pages","params":{"pages":[1],"degrees":90},"expected":undone["state"]["token"]}), + ) + .unwrap(); + assert_eq!(rotated["state"]["document"]["page"]["rotation"], 90); + assert_eq!(app.session.get(id).unwrap().info.pages.get(1).unwrap().rotation, 0); + let old = rotated["state"]["token"].clone(); + app.open_bytes("other.pdf", None, fixture()).unwrap(); + app.active = Some(1); + assert!( + app.community_agent("agent.execute", &json!({"command":"undo","params":{},"expected":old})).unwrap_err().contains("document changed") + ); + } + #[test] + fn rejects_bad_targets_without_changing_pdf() { + let bytes = fixture(); + let mut app = PdfCraftApp::new(); + app.open_bytes("fixture.pdf", None, bytes.clone()).unwrap(); + let state = app.agent_state(&json!({})).unwrap(); + for (command, params) in [ + ("replace_text_line", json!({"page":1,"line":0,"expected_text":"wrong","text":"New"})), + ("rotate_pages", json!({"pages":[1,1],"degrees":90})), + ("rotate_pages", json!({"pages":[0],"degrees":90})), + ("add_text", json!({"page":1,"x":-1,"y":10,"width":100,"size":12,"text":"No"})), + ("apply_redactions", json!({})), + ] { + assert!(app.community_agent("agent.execute", &json!({"command":command,"params":params,"expected":state["token"]})).is_err()); + } + let (_, id) = app.active_ids().unwrap(); + assert_eq!(app.session.save_bytes(id).unwrap().as_ref(), &bytes); + } + #[test] + fn refuses_copy_restricted_pdf() { + use std::sync::Arc; + let mut session = pdfcraft_engine::Session::new(); + let id = session.open("restricted.pdf", None, Arc::new(fixture()), None).unwrap(); + session + .apply( + id, + Edit::Protect(pdfcraft_engine::Protection { + open_password: Some("reader".into()), + permissions_password: Some("owner".into()), + ..Default::default() + }), + ) + .unwrap(); + let bytes = session.save_bytes(id).unwrap(); + let mut app = PdfCraftApp::new(); + app.try_open("restricted.pdf", None, bytes, Some("reader"), true).unwrap(); + assert!(app.community_agent("agent.state", &json!({})).is_err(), "Agent must honour the PDF copying permission"); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index eaa102b..752ceb2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -179,6 +179,7 @@ pub fn attach(ctx: &egui::Context) -> (Control, ControlClient) { /// What a request needs from the app (implemented by `PdfCraftApp`). pub(crate) trait Host { + fn agent(&mut self, method: &str, params: &Value) -> Reply; fn state(&self) -> Value; fn command(&mut self, id: &str) -> Reply; fn dock(&mut self, reset: bool, params: &Value) -> Reply; @@ -276,6 +277,7 @@ impl Control { fn handle(&mut self, ctx: &egui::Context, host: &mut impl Host, method: &str, p: &Value) -> Handled { let str_param = |k: &str| p.get(k).and_then(Value::as_str).ok_or_else(|| format!("{method}: missing string parameter {k}")); let r: Result = (|| match method { + "agent.state" | "agent.execute" => Ok(Handled::Now(host.agent(method, p))), "ui.state" => { let mut state = host.state(); let size = ctx.input(|i| i.viewport_rect().size()); @@ -632,6 +634,9 @@ fn screenshot_png(image: &egui::ColorImage, region: Option, ppp: f32 } impl Host for crate::PdfCraftApp { + fn agent(&mut self, method: &str, params: &Value) -> Reply { + self.community_agent(method, params) + } fn dock(&mut self, reset: bool, params: &Value) -> Reply { crate::docking::command(self, reset, params) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f800ad6..50cbbbd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -28,6 +28,7 @@ mod appearance; pub mod canvas; mod chrome; mod combine_grid; +mod community_agent; mod image_pick; mod tags_ui; pub use combine_grid::ThumbState as CombineThumb; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4f5aaa7..a9ce5a1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -499,24 +499,12 @@ pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { cancel } -/// The ArtCraft wordmark (Storyteller's brand, docs/brand/; not open source), sized to `height`. +/// Community builds use plain text instead of the upstream trademark artwork. pub fn artcraft_logo(ui: &mut egui::Ui, height: f32) -> Response { - let dark = ui.visuals().dark_mode; - let (uri, bytes): (&str, &'static [u8]) = if dark { - ("bytes://artcraft-logo-white.svg", include_bytes!("../../../docs/brand/artcraft-logo-white.svg")) - } else { - ("bytes://artcraft-logo.svg", include_bytes!("../../../docs/brand/artcraft-logo.svg")) - }; - ui.add(egui::Image::from_bytes(uri, bytes).max_height(height).alt_text("ArtCraft")) + ui.label(egui::RichText::new("PdfCraft Online").size(height)) } - -/// The ArtCraft mark (brand blue, works on light and dark), `size` points square. pub fn artcraft_mark(ui: &mut egui::Ui, size: f32) -> Response { - ui.add( - egui::Image::from_bytes("bytes://artcraft-mark.svg", include_bytes!("../../../docs/brand/artcraft-mark.svg")) - .fit_to_exact_size(vec2(size, size)) - .alt_text("ArtCraft"), - ) + ui.label(egui::RichText::new("PDF").size(size * 0.5)) } /// Buttons for every community link (`pdfcraft_engine::links`), Discord first and prominent. diff --git a/docs/roadmap.md b/docs/roadmap.md index fabb5e6..1c11865 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -198,3 +198,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-10 | minor | Re-judged convert and share with the cross-app gate rule (only a workflow that can't be completed at all blocks): partial, not blocking → stage alpha | | 2026-10-10 | minor | Added the alpha gate (six core workflows; convert and share fails → pre-alpha, 40–70 h to alpha) and rank 0 in the upcoming milestones | | 2026-10-10 | major | Created from ROADMAP.md §Milestones, §Critical path, §Risks and §Where we're lacking. Merged the duplicated M1, M2, M3 and M14 rows, re-measured Done and Remaining, added Current focus and the ranked upcoming milestones | + +## Community browser build — 2026-10-17 + +The pdfcraft.run fork adds an opt-in in-page Agent bridge to existing Session editing and undo, with document revision guards and bounded text/page tools. This is a site-maintained patch on v0.8.0; upstream milestone and parity ratings are unchanged. Verification evidence is maintained with the site patch and deployment log.