From 1bd2b131a72c865fd121cd16ac027bb04c23dbdd Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:36:10 -0700 Subject: [PATCH 1/4] pdf service v2: open documents for PDF Tools v2 The pdf host service kept no state: every call opened a fresh engine session and the file, for five methods. PDF Tools v2's contract (apps/pdftools/design/SERVICE.md on design/pdftools-v2) needs open documents whose edits and undo history last across calls, page renders at screen resolution, find with match rectangles, comments, Fill & Sign, form fields, page operations, text edits, undo, redo and save. - Open documents keyed by caller app, storage scope and handle, at most 8 per caller, in a UI-thread thread_local: every call arrives on the UI thread (App Hub's pump, the relay's host_tools::pump), so no lock. Released on pdf.close and when the opening isolate's storage scope goes away (makepad's splash_storage), swept on every call because App Hub has no isolate-closed hook; callers with no isolate get a 15-minute idle bound. - Every SERVICE.md method on the engine commands reviewed for it, with arguments the service builds; every v2 method refuses keys it does not take. Writing file commands get only a staging path inside the storage; comment_add never sees its attachment type or a path; doc_open and form_fill run with JavaScript off, proven by a scripted form and the engine's scripted XFA form. - Caps: 24-300 dpi and 16 MP a render, find at most 500, 512 pages a call, a render cache of 16 MiB and 64 files (an app's storage holds 256 entries), cleared before a write would fail with storage_full; stable error codes on every method. - engine-area: Area::credit, bytes a call freed outside its own writes. - Docs: apps/AGENTS.md, ADR 0013's pdf row in both languages. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/AGENTS.md | 29 + apps/pdf/host-service/Cargo.toml | 18 +- .../host-service/examples/pdftools_fixture.rs | 7 +- apps/pdf/host-service/src/args.rs | 146 +++ apps/pdf/host-service/src/cache.rs | 218 ++++ apps/pdf/host-service/src/change.rs | 292 ++++++ apps/pdf/host-service/src/codes.rs | 116 ++ apps/pdf/host-service/src/doc_tests.rs | 989 ++++++++++++++++++ apps/pdf/host-service/src/docs.rs | 366 +++++++ apps/pdf/host-service/src/lib.rs | 436 +++++--- apps/pdf/host-service/src/reading.rs | 255 +++++ apps/pdf/host-service/src/review.rs | 412 ++++++++ crates/engine-area/src/lib.rs | 28 + .../0013-craft-engines-as-pinned-services.md | 2 +- ...-craft-engines-as-pinned-services.zh-CN.md | 2 +- 16 files changed, 3157 insertions(+), 160 deletions(-) create mode 100644 apps/pdf/host-service/src/args.rs create mode 100644 apps/pdf/host-service/src/cache.rs create mode 100644 apps/pdf/host-service/src/change.rs create mode 100644 apps/pdf/host-service/src/codes.rs create mode 100644 apps/pdf/host-service/src/doc_tests.rs create mode 100644 apps/pdf/host-service/src/docs.rs create mode 100644 apps/pdf/host-service/src/reading.rs create mode 100644 apps/pdf/host-service/src/review.rs diff --git a/Cargo.lock b/Cargo.lock index a89955b36..d4eccb2d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11793,6 +11793,7 @@ dependencies = [ name = "octosense-pdf-service" version = "0.1.0" dependencies = [ + "makepad-widgets", "octosense-app-policy", "octosense-appstore", "octosense-engine-area", diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 2782cdda1..be66c5421 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -81,6 +81,35 @@ app's own agent and `tools.json`, the system toolbox, `glance.publish` and shell by `apps/pdftools/tests/ui.py` on the sample PDFs its `pdftools_fixture` example writes, see `apps/pdftools/README.md`): `apps/pdf/host-service` and `cargo test --locked -p octosense-pdf-service`. + Besides the file methods (`info`, `text`, `render`, `merge`, `split`: the + system agent's five tools, which stay as they are), it serves PDF Tools + v2's open documents to apps' own requests, as + `apps/pdftools/design/SERVICE.md` specifies; change that contract and the + service together. `pdf.open` keeps the engine's document, its edits and + undo history, under a handle; a document belongs to the caller app, its + storage scope (the area's root) and the handle, so another app's or + scope's handle is `unknown_doc:`, and a caller keeps at most 8 open + (`src/docs.rs`). The table is a `thread_local`, because every call reaches + the service on the UI thread (App Hub's `services::pump` inline from the + card runner, the relay's and components' calls from `host_tools::pump`), + which must take no lock another thread can hold. `pdf.close` releases a + document, and so does its app closing: a document is bound to the opening + isolate's storage scope (Makepad's `splash_storage::storage_for_heap` for + `Replier::isolate_key()`, gone when App Hub's card shutdown clears the + isolate's host tag), which every call sweeps first, App Hub having no + "isolate closed" hook for services; a caller with no isolate gets a + 15-minute idle bound. Each method runs reviewed engine commands, its + review beside it (`src/reading.rs`, `src/review.rs`, `src/change.rs`): the + service builds every engine argument itself and each v2 method refuses a + key it does not take, so `comment_add` (`file`) never sees its attachment + type or a path; `file` commands that write get a staging path inside the + storage only; `doc_open` and `form_fill` (`code`) run with JavaScript off, + proven by `doc_tests::a_forms_own_scripts_never_run`. Caps: 24–300 dpi and + 16 MP a render, `find` ≤ 500 matches, 512 pages a call, and a render cache + in `.cache/pages/` of 16 MiB and 64 files (an app's storage holds at most + 256 entries for its own writes), oldest first, cleared before a write + would fail with `storage_full:`. Errors start with a stable code + (`src/codes.rs`). - Declare an app's agent in its manifest and `bundle/tools.json`. Keep the input/output schemas consistent with the executor (octos requires an object output schema), and select the actual risk, sharing and confirmation policy. diff --git a/apps/pdf/host-service/Cargo.toml b/apps/pdf/host-service/Cargo.toml index 44cf02e4c..18dd8d612 100644 --- a/apps/pdf/host-service/Cargo.toml +++ b/apps/pdf/host-service/Cargo.toml @@ -1,11 +1,12 @@ # octosense-pdf-service — the `pdf` host service (ADR 0013). # # pdfcraft's PDF engine behind typed `pdf.*` methods, through its own -# headless automation layer: inspect a document, extract its text, render -# a page to PNG, merge files and split one, with every path confined to -# the call's area: the caller's own folder from the shell's resolver, else -# the legacy `pdf/` corner of the caller's host directory. -# Stateless: every call opens, acts and closes. +# headless automation layer, with every path confined to the call's area: +# the caller's own folder from the shell's resolver, else the legacy `pdf/` +# corner of the caller's host directory. A call that names a file opens, +# acts and closes (inspect, extract text, render a page, merge, split); +# PDF Tools v2's open documents keep the engine's document, its edits and +# undo history between calls (apps/pdftools/design/SERVICE.md). [package] name = "octosense-pdf-service" version = "0.1.0" @@ -14,7 +15,9 @@ license = "Apache-2.0" publish = false [dependencies] -# The same App Hub rev as Mail and News: one appstore, one service registry. +# The same App Hub rev as Mail and News: one appstore, one service registry +# (and, through it, Makepad's per-isolate storage scope an open document is +# bound to). octosense-appstore = { workspace = true } # Where a call works: the caller's area and its write rules (ADR 0013). octosense-engine-area = { workspace = true } @@ -30,4 +33,7 @@ octosense-skill-gen = { workspace = true } octosense-app-policy = { workspace = true } # The engine's own scripted XFA form: proof that a document's scripts stay off. pdfcraft-xfa = { workspace = true } +# A real app isolate with a storage scope, whose closing releases the +# documents it opened (src/doc_tests.rs). +makepad-widgets = { workspace = true } tempfile = "3" diff --git a/apps/pdf/host-service/examples/pdftools_fixture.rs b/apps/pdf/host-service/examples/pdftools_fixture.rs index 783a64a21..ed9f6259a 100644 --- a/apps/pdf/host-service/examples/pdftools_fixture.rs +++ b/apps/pdf/host-service/examples/pdftools_fixture.rs @@ -48,8 +48,9 @@ fn main() { } } -/// The samples: file name and bytes. -fn documents() -> Vec<(&'static str, Vec)> { +/// The samples: file name and bytes. (The service's own tests include +/// this file as a module and use them too.) +pub(crate) fn documents() -> Vec<(&'static str, Vec)> { vec![ ("Quarterly report.pdf", quarterly_report()), ("Board minutes.pdf", board_minutes()), @@ -513,7 +514,7 @@ fn apartment_lease() -> Vec { pdf(&info(title, "Harbour Lane Homes", "Lease for 14 Harbour Lane, flat 3B", "lease, rental, agreement"), pages) } -fn garden_plan() -> Vec { +pub(crate) fn garden_plan() -> Vec { let title = "Garden plan - spring 2027"; let beds = [ ("Bed 1: salad", 0x6a994e, "Lettuce, rocket and radishes, sown every three weeks from March for a steady supply."), diff --git a/apps/pdf/host-service/src/args.rs b/apps/pdf/host-service/src/args.rs new file mode 100644 index 000000000..d6e1c2708 --- /dev/null +++ b/apps/pdf/host-service/src/args.rs @@ -0,0 +1,146 @@ +//! Reading a call's arguments. Every helper refuses with `invalid:` (a cap +//! with `too_large:`) and names the key. Integers may arrive as whole +//! floats (`3.0`): a script's numbers are doubles. + +use serde_json::Value as Json; + +use crate::codes::{fail, invalid, Code}; + +/// The most pages one call names (SERVICE.md: "at most 512 pages named in +/// one call"). +pub(crate) const MAX_PAGES: usize = 512; +/// The most rectangles one comment marks. +pub(crate) const MAX_RECTS: usize = 256; + +/// Refuse any argument `method` does not take. The engine command behind +/// a method only ever gets the parameters reviewed for it, which the +/// service builds itself; a key it was not reviewed for (a file `path` on +/// a comment) is refused here, before any engine work. A key set to `null` +/// counts as absent. +pub(crate) fn only(args: &Json, keys: &[&str], method: &str) -> Result<(), String> { + match args { + Json::Null => Ok(()), + Json::Object(map) => match map.iter().find(|(k, v)| !v.is_null() && !keys.contains(&k.as_str())) { + Some((k, _)) => Err(invalid(format!("{method} takes {}; `{k}` is not one of them", keys.join(", ")))), + None => Ok(()), + }, + _ => Err(invalid(format!("{method}'s arguments are an object"))), + } +} + +fn present<'a>(args: &'a Json, key: &str) -> Option<&'a Json> { + args.get(key).filter(|v| !v.is_null()) +} + +/// A required, non-empty string. +pub(crate) fn need_str<'a>(args: &'a Json, key: &str, m: &str) -> Result<&'a str, String> { + present(args, key).and_then(Json::as_str).filter(|s| !s.is_empty()).ok_or_else(|| invalid(format!("{m} needs `{key}`, a string"))) +} + +/// An optional string of at most `max` characters. +pub(crate) fn opt_str<'a>(args: &'a Json, key: &str, m: &str, max: usize) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + let s = v.as_str().ok_or_else(|| invalid(format!("{m}: `{key}` is a string")))?; + if s.chars().count() > max { + return Err(fail(Code::TooLarge, format!("{m}: `{key}` is at most {max} characters"))); + } + Ok(Some(s)) +} + +/// A whole number, from an integer or a whole float. +fn whole(v: &Json) -> Option { + v.as_i64().or_else(|| v.as_f64().filter(|f| f.fract() == 0.0 && f.abs() < 9.0e15).map(|f| f as i64)) +} + +/// An optional whole number. +pub(crate) fn opt_int(args: &Json, key: &str, m: &str) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + whole(v).map(Some).ok_or_else(|| invalid(format!("{m}: `{key}` is a whole number"))) +} + +/// A required whole number ≥ 1 (a 1-based page, a position). +pub(crate) fn positive(args: &Json, key: &str, m: &str) -> Result { + match opt_int(args, key, m)? { + Some(n) if n >= 1 => Ok(n as u64), + _ => Err(invalid(format!("{m} needs `{key}`, a whole number from 1"))), + } +} + +pub(crate) fn opt_bool(args: &Json, key: &str, m: &str) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + v.as_bool().map(Some).ok_or_else(|| invalid(format!("{m}: `{key}` is true or false"))) +} + +/// 1-based page numbers: 1 to [`MAX_PAGES`] of them. +pub(crate) fn opt_pages(args: &Json, key: &str, m: &str) -> Result>, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + let list = v.as_array().ok_or_else(|| invalid(format!("{m}: `{key}` is a list of 1-based page numbers")))?; + if list.is_empty() { + return Err(invalid(format!("{m}: `{key}` names at least one page"))); + } + if list.len() > MAX_PAGES { + return Err(fail(Code::TooLarge, format!("{m}: `{key}` names at most {MAX_PAGES} pages in one call"))); + } + list.iter() + .map(|p| whole(p).filter(|n| *n >= 1).map(|n| n as u64).ok_or_else(|| invalid(format!("{m}: `{key}` holds 1-based page numbers")))) + .collect::, _>>() + .map(Some) +} + +pub(crate) fn pages(args: &Json, key: &str, m: &str) -> Result, String> { + opt_pages(args, key, m)?.ok_or_else(|| invalid(format!("{m} needs `{key}`, a list of 1-based page numbers"))) +} + +fn finite(v: &Json) -> Option { + v.as_f64().filter(|f| f.is_finite() && f.abs() <= 1.0e6) +} + +/// A point `[x, y]` in points from the top-left of the displayed page. +pub(crate) fn opt_point(args: &Json, key: &str, m: &str) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + match v.as_array().map(|a| a.iter().map(finite).collect::>>()) { + Some(Some(p)) if p.len() == 2 => Ok(Some([p[0], p[1]])), + _ => Err(invalid(format!("{m}: `{key}` is [x, y] in points"))), + } +} + +/// Rectangles `[[x, y, w, h], …]` in points from the top-left of the +/// displayed page, each with a positive size: 1 to [`MAX_RECTS`] of them. +pub(crate) fn opt_rects(args: &Json, key: &str, m: &str) -> Result>, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + let wrong = || invalid(format!("{m}: `{key}` is a list of [x, y, w, h] in points, each with a positive width and height")); + let list = v.as_array().filter(|l| !l.is_empty()).ok_or_else(wrong)?; + if list.len() > MAX_RECTS { + return Err(fail(Code::TooLarge, format!("{m}: `{key}` holds at most {MAX_RECTS} rectangles"))); + } + list.iter() + .map(|r| match r.as_array().map(|a| a.iter().map(finite).collect::>>()) { + Some(Some(r)) if r.len() == 4 && r[2] > 0.0 && r[3] > 0.0 => Ok([r[0], r[1], r[2], r[3]]), + _ => Err(wrong()), + }) + .collect::, _>>() + .map(Some) +} + +/// A resolution in dots per inch, `lo..=hi`, rounded to a whole number. +pub(crate) fn dpi(args: &Json, key: &str, m: &str, default: u32, (lo, hi): (u32, u32)) -> Result { + let Some(v) = present(args, key) else { return Ok(default) }; + let d = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| invalid(format!("{m}: `{key}` is a number")))?.round(); + if d < f64::from(lo) || d > f64::from(hi) { + return Err(invalid(format!("{m}: `{key}` is {lo} to {hi}"))); + } + Ok(d as u32) +} + +/// Engine geometry `[x0, y0, x1, y1]` (top-left origin) as SERVICE.md's +/// `[x, y, w, h]`, to 1/100 pt; `null` when it is not four numbers. +pub(crate) fn xywh(r: &Json) -> Json { + let n: Option> = r.as_array().map(|a| a.iter().filter_map(Json::as_f64).collect()); + match n { + Some(v) if v.len() == 4 => { + let round = |x: f64| (x * 100.0).round() / 100.0; + serde_json::json!([round(v[0].min(v[2])), round(v[1].min(v[3])), round((v[2] - v[0]).abs()), round((v[3] - v[1]).abs())]) + } + _ => Json::Null, + } +} diff --git a/apps/pdf/host-service/src/cache.rs b/apps/pdf/host-service/src/cache.rs new file mode 100644 index 000000000..b9bcf118e --- /dev/null +++ b/apps/pdf/host-service/src/cache.rs @@ -0,0 +1,218 @@ +//! The render cache (SERVICE.md "Reading"): `pdf.page` writes each page it +//! renders to `.cache/pages//@.png` in the caller's storage. +//! +//! - At most [`MAX_BYTES`] (16 MiB) and [`MAX_FILES`] renders per storage; +//! the oldest renders (by when they were written) go first. +//! - The file cap is the service's own: an app's storage holds at most 256 +//! entries for the app's own `fs` writes and `files.import` (makepad's +//! `splash_storage::MAX_ENTRIES`), so a cache of hundreds of thumbnails +//! would leave the app unable to save its library or import a PDF. +//! - Before any write of the service would fail for room +//! (`storage_full:`), the whole cache is cleared ([`make_room`]). +//! - The cache is the service's: a render replaces a stale render of the +//! same page and resolution, from any surface. It never walks or writes +//! through a link: a cache folder that is a link is refused, and only +//! regular files under plain folders are counted or removed. + +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use octosense_engine_area::Area; + +use crate::codes::{self, fail, invalid, Code}; + +/// The cache, relative to the storage. +pub(crate) const DIR: &str = ".cache/pages"; +/// The most bytes of renders one storage keeps. +pub(crate) const MAX_BYTES: u64 = 16 << 20; +/// The most renders one storage keeps. +pub(crate) const MAX_FILES: usize = 64; + +/// Where `pdf.page` writes page `page` of document `handle` at `dpi`. +pub(crate) fn render_path(handle: &str, page: u64, dpi: u32) -> String { + format!("{DIR}/{handle}/{page}@{dpi}.png") +} + +/// Whether `path` is a plain folder (`Ok(false)`: not there); a link or a +/// file in the way is refused. +fn plain_dir(path: &Path, shown: &str) -> Result { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.is_dir() => Ok(true), + Ok(_) => Err(invalid(format!("the render cache folder `{shown}` is not a plain folder"))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(invalid(format!("the render cache folder `{shown}`: {e}"))), + } +} + +/// The cache's folder in `area`, when it is there and plain all the way. +fn root(area: &Area) -> Result, String> { + let dot = area.root.join(".cache"); + if !plain_dir(&dot, ".cache")? { + return Ok(None); + } + let pages = dot.join("pages"); + Ok(plain_dir(&pages, DIR)?.then_some(pages)) +} + +struct Render { + path: PathBuf, + len: u64, + written: SystemTime, +} + +/// Every render in the cache: regular files in the documents' plain +/// folders. Links and anything else are neither counted nor followed. +fn renders(root: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(docs) = std::fs::read_dir(root) else { return out }; + for doc in docs.flatten() { + if !std::fs::symlink_metadata(doc.path()).is_ok_and(|m| m.is_dir()) { + continue; + } + let Ok(files) = std::fs::read_dir(doc.path()) else { continue }; + for file in files.flatten() { + let Ok(meta) = std::fs::symlink_metadata(file.path()) else { continue }; + if meta.is_file() { + out.push(Render { path: file.path(), len: meta.len(), written: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH) }); + } + } + } + out +} + +/// Remove a document's folder when it is empty (never a link). +fn drop_if_empty(dir: &Path) { + if std::fs::symlink_metadata(dir).is_ok_and(|m| m.is_dir()) { + let _ = std::fs::remove_dir(dir); + } +} + +/// The limits [`evict`] keeps to: the cache's own, or a test's. +#[derive(Clone, Copy)] +pub(crate) struct Limits { + pub bytes: u64, + pub files: usize, +} + +pub(crate) const LIMITS: Limits = Limits { bytes: MAX_BYTES, files: MAX_FILES }; + +/// Make room in the cache at `root` for one more render of `incoming` +/// bytes at `keep` (which it replaces, so it does not count): the oldest +/// renders go first until the cache with it fits `limits`. +pub(crate) fn evict(root: &Path, keep: &Path, incoming: u64, limits: Limits) { + let mut all: Vec = renders(root).into_iter().filter(|r| r.path != keep).collect(); + all.sort_by_key(|r| r.written); + let mut bytes: u64 = all.iter().map(|r| r.len).sum(); + let mut files = all.len(); + for old in &all { + if bytes.saturating_add(incoming) <= limits.bytes && files < limits.files { + break; + } + if std::fs::remove_file(&old.path).is_ok() { + bytes = bytes.saturating_sub(old.len); + files -= 1; + if let Some(dir) = old.path.parent().filter(|d| Some(*d) != keep.parent()) { + drop_if_empty(dir); + } + } + } +} + +/// Clear the whole cache; the bytes it held. +fn clear(area: &Area) -> u64 { + let Ok(Some(root)) = root(area) else { return 0 }; + let mut freed = 0; + for old in renders(&root) { + if std::fs::remove_file(&old.path).is_ok() { + freed += old.len; + } + if let Some(dir) = old.path.parent() { + drop_if_empty(dir); + } + } + freed +} + +/// Before a write of `need` bytes in `area`: when the storage has less +/// room left, clear the render cache first and give the call the room it +/// freed. The write's own check then answers `storage_full:` if that was +/// not enough. +pub(crate) fn make_room(area: &Area, need: u64) { + if area.room().is_some_and(|room| need > room) { + area.credit(clear(area)); + } +} + +/// The size of the regular file at `path` (0: none, or a link). +pub(crate) fn regular_len(path: &Path) -> u64 { + std::fs::symlink_metadata(path).ok().filter(|m| m.is_file()).map_or(0, |m| m.len()) +} + +/// Whether the render at `rel` is still the `len` bytes this service +/// wrote: the cache may have been cleared or trimmed since. +pub(crate) fn still_there(area: &Area, rel: &str, len: u64) -> bool { + std::fs::symlink_metadata(area.root.join(rel)).is_ok_and(|m| m.is_file() && m.len() == len) +} + +/// Write `png` to the cache at `rel` (from [`render_path`]), within the +/// cache's bounds and the storage's room. +pub(crate) fn store(area: &Area, rel: &str, png: &[u8]) -> Result<(), String> { + let len = png.len() as u64; + if len > MAX_BYTES { + return Err(fail(Code::TooLarge, format!("this page renders to {} MB, more than the {} MB render cache holds: choose a lower dpi", len >> 20, MAX_BYTES >> 20))); + } + let target = crate::contained(area, rel, "path", "pdf.page")?; + // The folders on the way must be plain: a link would have the cache + // count, trim or write files somewhere else in the storage. + root(area)?; + if let Some(dir) = target.parent() { + plain_dir(dir, &area.shown(dir))?; + } + if let Some(root) = root(area)? { + evict(&root, &target, len, LIMITS); + } + make_room(area, len.saturating_sub(regular_len(&target))); + // The service's own cache: a stale render of the same page goes, by an + // atomic rename that replaces a link rather than writing through it. + Area::new(area.root.clone(), area.room(), true).write(&target, png).map_err(codes::area) +} + +/// Remove the renders of document `handle` (it closed). +pub(crate) fn forget(area: &Area, handle: &str) { + if let Ok(Some(root)) = root(area) { + remove_doc(&root.join(handle)); + } +} + +/// Remove the renders of every document in `area` whose handle is not in +/// `open`: documents closed, released, or opened by an earlier run. +pub(crate) fn prune(area: &Area, open: &[String]) { + let Ok(Some(root)) = root(area) else { return }; + let Ok(docs) = std::fs::read_dir(&root) else { return }; + for doc in docs.flatten() { + if !open.iter().any(|h| doc.file_name().to_str() == Some(h.as_str())) { + remove_doc(&doc.path()); + } + } +} + +/// A document's cache folder and the regular files in it; a link there is +/// removed itself, never followed. +fn remove_doc(dir: &Path) { + match std::fs::symlink_metadata(dir) { + Ok(meta) if meta.is_dir() => { + if let Ok(files) = std::fs::read_dir(dir) { + for file in files.flatten() { + if std::fs::symlink_metadata(file.path()).is_ok_and(|m| !m.is_dir()) { + let _ = std::fs::remove_file(file.path()); + } + } + } + let _ = std::fs::remove_dir(dir); + } + Ok(_) => { + let _ = std::fs::remove_file(dir); + } + Err(_) => {} + } +} diff --git a/apps/pdf/host-service/src/change.rs b/apps/pdf/host-service/src/change.rs new file mode 100644 index 000000000..b64e8555b --- /dev/null +++ b/apps/pdf/host-service/src/change.rs @@ -0,0 +1,292 @@ +//! Changing an open document and writing it out (SERVICE.md "Pages", +//! "Edit", "History and saving", "Combine, split, export"). +//! +//! The safety review of the engine commands behind them: +//! +//! - `page_rotate`, `page_delete`, `page_move`, `page_duplicate`, +//! `text_edit`, `edit_undo`, `edit_redo` and `doc_list` are `safe`: +//! undoable edits of the open document, or reads of it. +//! - `page_insert_file` is `file`: it reads the PDF at `path`. The path is +//! the caller's, contained in its area and at most 128 MiB +//! ([`crate::checked_input`]), and the engine's root-confined resolver +//! checks it again at read time. Its `pages` are 1-based numbers, at +//! most 512. +//! - `page_extract`, `doc_save`, `doc_export_images` and `doc_export_text` +//! are `file`: they write the paths they are given. The service gives +//! them only a path in a staging folder inside the area +//! (`octosense_engine_area::Stage`), and moves the result into place +//! under the area's rules: contained, within what is left of the +//! storage, and never over an existing file except the one write +//! SERVICE.md allows, an incremental save over the document's own file. +//! `page_extract` gets no `delete`, `separate`, `out_dir` or `open`; +//! `doc_export_images` gets no format but PNG; `doc_save` gets `full` +//! as the service decides. + +use std::path::{Path, PathBuf}; + +use octosense_engine_area::{Area, Stage}; +use serde_json::{json, Value as Json}; + +use crate::args; +use crate::cache; +use crate::codes::{self, fail, invalid, Code}; +use crate::docs::{self, OpenDoc}; +use crate::reading; +use crate::Ctx; + +/// `pdf.export images`' resolutions (SERVICE.md: at most 300 dpi) and its +/// default, the engine's. +const EXPORT_DPI: (u32, u32) = (24, 300); +const EXPORT_DEFAULT_DPI: u32 = 150; +/// The longest text an edit types. +const MAX_EDIT: usize = 10_000; + +/// Move what the engine wrote in `stage` into place under `area`'s rules, +/// clearing the render cache first when the storage would be too full. +pub(crate) fn commit(area: &Area, stage: &Stage<'_>, moves: &[(PathBuf, PathBuf)]) -> Result<(), String> { + let added: u64 = moves.iter().map(|(from, _)| cache::regular_len(from)).sum(); + let freed: u64 = if area.may_replace { moves.iter().map(|(_, to)| cache::regular_len(to)).sum() } else { 0 }; + cache::make_room(area, added.saturating_sub(freed)); + stage.commit(moves).map_err(codes::area) +} + +/// An area like `area` whose writes never replace a file, whatever the +/// call's surface: where SERVICE.md says "a new file". +fn create_only(area: &Area) -> Area { + Area::new(area.root.clone(), area.room(), false) +} + +/// The file name of `rel` (a path already contained). +fn file_name(rel: &str, m: &str) -> Result { + Path::new(rel).file_name().map(|n| n.to_string_lossy().into_owned()).ok_or_else(|| invalid(format!("{m}: `{rel}` names no file"))) +} + +/// The pages of one change, after it: the document is new, its renders +/// stale, and its page sizes read again. +fn after_change(doc: &mut OpenDoc) -> Result { + doc.changed(); + doc.refresh()?; + Ok(json!({ "pages": doc.sizes.len() })) +} + +/// `pdf.pages {doc, op, …}`. +pub(crate) fn pages(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.pages"; + let op = args::need_str(a, "op", M)?; + match op { + "rotate" => { + args::only(a, &["doc", "op", "pages", "angle"], M)?; + let pages = args::pages(a, "pages", M)?; + let angle = args::opt_int(a, "angle", M)?.filter(|d| matches!(d, 90 | -90 | 180 | -180)).ok_or_else(|| invalid(format!("{M}: `angle` is 90, -90 or 180")))?; + docs::with_doc(a, cx, M, |doc| { + doc.call("page_rotate", json!({ "pages": pages, "degrees": angle })).map_err(|e| codes::refused(&e))?; + after_change(doc) + }) + } + "delete" => { + args::only(a, &["doc", "op", "pages"], M)?; + let pages = args::pages(a, "pages", M)?; + docs::with_doc(a, cx, M, |doc| { + let mut distinct = pages.clone(); + distinct.sort_unstable(); + distinct.dedup(); + if distinct.len() >= doc.sizes.len() && distinct.iter().all(|p| *p as usize <= doc.sizes.len()) { + return Err(invalid("a document keeps at least one page: deleting every page is refused")); + } + doc.call("page_delete", json!({ "pages": pages })).map_err(|e| codes::refused(&e))?; + after_change(doc) + }) + } + "move" => { + args::only(a, &["doc", "op", "pages", "to"], M)?; + let pages = args::pages(a, "pages", M)?; + let to = args::positive(a, "to", M)?; + docs::with_doc(a, cx, M, |doc| { + doc.call("page_move", json!({ "pages": pages, "to": to })).map_err(|e| codes::refused(&e))?; + after_change(doc) + }) + } + "duplicate" => { + args::only(a, &["doc", "op", "pages"], M)?; + let pages = args::pages(a, "pages", M)?; + docs::with_doc(a, cx, M, |doc| { + doc.call("page_duplicate", json!({ "pages": pages })).map_err(|e| codes::refused(&e))?; + after_change(doc) + }) + } + "insert_file" => { + args::only(a, &["doc", "op", "path", "at", "pages"], M)?; + let path = args::need_str(a, "path", M)?; + let at = args::positive(a, "at", M)?; + let pages = args::opt_pages(a, "pages", M)?; + crate::checked_input(cx.area, path, "path", M)?; + let mut call = json!({ "path": path, "at": at }); + if let Some(pages) = pages { + call["pages"] = json!(pages); + } + docs::with_doc(a, cx, M, |doc| { + doc.call("page_insert_file", call).map_err(|e| codes::read_or_refused(&e))?; + after_change(doc) + }) + } + "extract" => { + args::only(a, &["doc", "op", "pages", "out"], M)?; + let pages = args::pages(a, "pages", M)?; + let out = args::need_str(a, "out", M)?; + // A new file in storage, never over one, foreground or not. + let area = create_only(cx.area); + let dest = crate::out_path(&area, out, "out", M)?; + let name = file_name(out, M)?; + docs::with_doc(a, cx, M, |doc| { + let stage = area.stage().map_err(codes::area)?; + let staged = crate::staged(&area, &stage, &name)?; + doc.call("page_extract", json!({ "pages": pages, "out": staged, "open": false })).map_err(|e| codes::refused(&e))?; + commit(&area, &stage, &[(stage.path(&name), dest)])?; + Ok(json!({ "path": out })) + }) + } + other => Err(invalid(format!("{M}: `op` is rotate, delete, move, duplicate, insert_file or extract, not {other:?}"))), + } +} + +/// `pdf.edit_text {doc, page, paragraph?, line?, text}`. +pub(crate) fn edit_text(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.edit_text"; + args::only(a, &["doc", "page", "paragraph", "line", "text"], M)?; + let page = args::positive(a, "page", M)?; + let text = args::opt_str(a, "text", M, MAX_EDIT)?.ok_or_else(|| invalid(format!("{M} needs `text`")))?; + let paragraph = args::opt_int(a, "paragraph", M)?; + let line = args::opt_int(a, "line", M)?; + let mut call = json!({ "page": page, "text": text }); + match (paragraph, line) { + (Some(n), None) if n >= 1 => call["paragraph"] = json!(n), + (None, Some(n)) if n >= 1 => call["line"] = json!(n), + _ => return Err(invalid(format!("{M}: give exactly one of `paragraph` or `line`, a number from pdf.lines"))), + } + docs::with_doc(a, cx, M, |doc| { + doc.size(page)?; + doc.call("text_edit", call).map_err(|e| codes::refused(&e))?; + doc.changed(); + Ok(json!({ "edited": true })) + }) +} + +/// `pdf.undo {doc}` and `pdf.redo {doc}`. +pub(crate) fn history(a: &Json, cx: &Ctx, redo: bool) -> Result { + let m = if redo { "pdf.redo" } else { "pdf.undo" }; + args::only(a, &["doc"], m)?; + docs::with_doc(a, cx, m, |doc| { + let out = doc.call(if redo { "edit_redo" } else { "edit_undo" }, json!({})).map_err(|e| codes::refused(&e))?; + doc.changed(); + doc.refresh()?; + Ok(docs::history(&out["document"])) + }) +} + +/// `pdf.save {doc, path?}`: an incremental save to the document's own +/// file, or a full save to a new file, which the document then is. +pub(crate) fn save(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.save"; + args::only(a, &["doc", "path"], M)?; + let to = args::opt_str(a, "path", M, 512)?; + docs::with_doc(a, cx, M, |doc| { + let was_edited = doc.summary()?["dirty"] == true; + let (area, dest, rel, full) = match to { + None => { + // The one write that replaces a file: the document's own, + // which pdf.open opened for editing, from the foreground. + if !cx.area.may_replace { + return Err(invalid("saving over the document's own file needs the app in the foreground")); + } + let dest = crate::contained(cx.area, &doc.file, "path", M)?; + (Area::new(cx.area.root.clone(), cx.area.room(), true), dest, doc.file.clone(), false) + } + Some(out) => { + // A full save to a new file: never over an existing one. + let area = create_only(cx.area); + let dest = crate::out_path(&area, out, "path", M)?; + let rel: PathBuf = Path::new(out).components().collect(); + (area, dest, rel.to_string_lossy().into_owned(), true) + } + }; + let name = file_name(&rel, M)?; + let stage = area.stage().map_err(codes::area)?; + let staged = crate::staged(&area, &stage, &name)?; + let saved = doc.call("doc_save", json!({ "path": staged, "full": full })).map_err(|e| codes::refused(&e))?; + if let Err(e) = commit(&area, &stage, &[(stage.path(&name), dest)]) { + // The engine counts the document saved now, and it is not. An + // undo and a redo leave its content as it was and mark it edited + // again, so closing it still asks to save. + doc.changed(); + if was_edited && doc.call("edit_undo", json!({})).is_ok() && doc.call("edit_redo", json!({})).is_err() { + return Err(format!("{e} (and the document's last change was undone: redo it)")); + } + return Err(e); + } + // The engine reads the document's file from the staging folder it + // saved to: the service names its file itself from here on. + doc.file = rel.clone(); + Ok(json!({ "path": rel, "bytes": saved["bytes"] })) + }) +} + +/// `pdf.export {doc, kind: "images", out_dir, dpi?, pages?}` or +/// `{doc, kind: "text", out, pages?}`: new files only. +pub(crate) fn export(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.export"; + let kind = args::need_str(a, "kind", M)?; + let area = create_only(cx.area); + match kind { + "images" => { + args::only(a, &["doc", "kind", "out_dir", "dpi", "pages"], M)?; + let out_dir = args::need_str(a, "out_dir", M)?; + let dpi = args::dpi(a, "dpi", M, EXPORT_DEFAULT_DPI, EXPORT_DPI)?; + let pages = args::opt_pages(a, "pages", M)?; + let dir = crate::contained(&area, out_dir, "out_dir", M)?; + docs::with_doc(a, cx, M, |doc| { + let pages = all_pages(doc, pages, M)?; + for &p in &pages { + reading::check_pixels(doc.size(p)?, dpi, p)?; + } + let stage = area.stage().map_err(codes::area)?; + let staging = crate::staged(&area, &stage, "")?; + doc.call("doc_export_images", json!({ "folder": staging, "dpi": dpi, "pages": pages, "format": "png" })).map_err(|e| codes::refused(&e))?; + let moves: Vec<(PathBuf, PathBuf)> = stage.files().into_iter().map(|rel| (stage.path(&rel), dir.join(&rel))).collect(); + commit(&area, &stage, &moves)?; + let paths: Vec = stage_names(&moves).iter().map(|n| Path::new(out_dir).join(n).to_string_lossy().into_owned()).collect(); + Ok(json!({ "paths": paths })) + }) + } + "text" => { + args::only(a, &["doc", "kind", "out", "pages"], M)?; + let out = args::need_str(a, "out", M)?; + let pages = args::opt_pages(a, "pages", M)?; + let dest = crate::out_path(&area, out, "out", M)?; + let name = file_name(out, M)?; + docs::with_doc(a, cx, M, |doc| { + let pages = all_pages(doc, pages, M)?; + let stage = area.stage().map_err(codes::area)?; + let staged = crate::staged(&area, &stage, &name)?; + doc.call("doc_export_text", json!({ "path": staged, "pages": pages })).map_err(|e| codes::refused(&e))?; + commit(&area, &stage, &[(stage.path(&name), dest)])?; + Ok(json!({ "paths": [out] })) + }) + } + other => Err(invalid(format!("{M}: `kind` is images or text, not {other:?}"))), + } +} + +/// The pages an export names, or every page of a document of at most +/// [`args::MAX_PAGES`]. +fn all_pages(doc: &OpenDoc, pages: Option>, m: &str) -> Result, String> { + match pages { + Some(pages) => Ok(pages), + None if doc.sizes.len() <= args::MAX_PAGES => Ok((1..=doc.sizes.len() as u64).collect()), + None => Err(fail(Code::TooLarge, format!("{m}: the document has {} pages; pass `pages` (at most {} per call)", doc.sizes.len(), args::MAX_PAGES))), + } +} + +/// The file names the moves land on, in order. +fn stage_names(moves: &[(PathBuf, PathBuf)]) -> Vec { + moves.iter().filter_map(|(_, to)| to.file_name().map(|n| n.to_string_lossy().into_owned())).collect() +} diff --git a/apps/pdf/host-service/src/codes.rs b/apps/pdf/host-service/src/codes.rs new file mode 100644 index 000000000..7a0e3c9aa --- /dev/null +++ b/apps/pdf/host-service/src/codes.rs @@ -0,0 +1,116 @@ +//! The service's stable error codes (`apps/pdftools/design/SERVICE.md`): +//! every `Err` starts with one, then a colon and plain words for a person. +//! The words never name a host path: [`crate::serve`] takes the area's own +//! spelling out of every error. + +use std::fmt::Display; + +use pdfcraft_automation::ToolError; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Code { + /// The file a call names is not there. + NotFound, + /// The engine cannot read the file; its reason follows. + Damaged, + /// The PDF needs a password, and the service does not open those. + Protected, + /// The app's storage has no room for the result. + StorageFull, + /// The caller already has the most documents open. + TooManyOpen, + /// The handle names no document this caller has open. + UnknownDoc, + /// The document has edits that were never saved. + Unsaved, + /// The request is malformed, names something the method does not take, + /// or asks for something the document refuses. + Invalid, + /// The request is over one of the service's caps. + TooLarge, +} + +impl Code { + pub(crate) fn as_str(self) -> &'static str { + match self { + Code::NotFound => "not_found", + Code::Damaged => "damaged", + Code::Protected => "protected", + Code::StorageFull => "storage_full", + Code::TooManyOpen => "too_many_open", + Code::UnknownDoc => "unknown_doc", + Code::Unsaved => "unsaved", + Code::Invalid => "invalid", + Code::TooLarge => "too_large", + } + } +} + +/// An error: `code`, a colon, and `words`. +pub(crate) fn fail(code: Code, words: impl Display) -> String { + format!("{}: {words}", code.as_str()) +} + +/// `fail(Invalid, …)`, the most common refusal. +pub(crate) fn invalid(words: impl Display) -> String { + fail(Code::Invalid, words) +} + +/// Whether the engine's words say a password is needed (pdfcraft-render's +/// `NeedsPassword` and `WrongPassword` when it opens a document, and the +/// "it is password-protected" of a file it combines or inserts). +fn needs_password(words: &str) -> bool { + words.contains("protected by a password") || words.contains("password is incorrect") || words.contains("password-protected") +} + +/// The engine's refusal of a file it was asked to read (`doc_open`, and +/// the files an edit reads): a password is `protected:`, a missing file +/// `not_found:`, anything else `damaged:` with the engine's reason. +pub(crate) fn unreadable(e: &ToolError) -> String { + let words = e.to_string(); + if needs_password(&words) { + fail(Code::Protected, "this PDF is protected with a password, and PDF Tools does not open those yet") + } else if words.contains("No such file") || words.contains("cannot find the file") { + fail(Code::NotFound, words) + } else { + fail(Code::Damaged, words) + } +} + +/// The engine's refusal of an edit or a read of an open document: what +/// the call asked for does not fit the document (a page out of range, a +/// comment that is not there, nothing to undo, a read-only document). +/// A file the edit read that needs a password is `protected:`. +pub(crate) fn refused(e: &ToolError) -> String { + let words = e.to_string(); + if needs_password(&words) { + fail(Code::Protected, "that PDF is protected with a password, and PDF Tools does not open those yet") + } else { + invalid(words) + } +} + +/// The engine's refusal of a command that reads another file (pages +/// inserted from a PDF, files combined): that file needing a password is +/// `protected:`, missing `not_found:`, unreadable `damaged:`; anything +/// else is the request not fitting (`invalid:`). +pub(crate) fn read_or_refused(e: &ToolError) -> String { + let words = e.to_string(); + if needs_password(&words) || words.contains("No such file") || words.contains("not a readable PDF") { + unreadable(e) + } else { + invalid(words) + } +} + +/// A refusal from the area's write rules (`octosense_engine_area`): over +/// the quota is `storage_full:`, anything else (a name that is taken, a +/// folder in the way) `invalid:`. +pub(crate) fn area(words: impl Display) -> String { + let words = words.to_string(); + if words.contains("bytes left in this storage") || words.contains("No space left") { + fail(Code::StorageFull, words) + } else { + invalid(words) + } +} diff --git a/apps/pdf/host-service/src/doc_tests.rs b/apps/pdf/host-service/src/doc_tests.rs new file mode 100644 index 000000000..ac5830b62 --- /dev/null +++ b/apps/pdf/host-service/src/doc_tests.rs @@ -0,0 +1,989 @@ +//! The open-document methods (PDF Tools v2, `apps/pdftools/design/SERVICE.md`), +//! through [`serve`] as App Hub hands calls to the service, on the sample PDFs +//! the `pdftools_fixture` example writes and on PDFs built here. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value as Json}; + +use super::tests::{no_staging_left, resolver, tiny_pdf}; +use super::*; + +/// The sample PDFs of `examples/pdftools_fixture.rs`. +#[allow(dead_code)] +#[path = "../examples/pdftools_fixture.rs"] +mod fixture; + +const APP: &str = "os.pdftools"; + +/// An app's storage, served as the shell serves an app's own requests. +struct World { + _dir: tempfile::TempDir, + root: PathBuf, + areas: Slot, +} + +impl World { + fn new() -> World { + World::with_quota(None) + } + + fn with_quota(quota: Option) -> World { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("storage"); + std::fs::create_dir(&root).unwrap(); + let areas = resolver(&root, quota); + World { _dir: dir, root, areas } + } + + /// The sample PDFs, at the top of the storage. + fn samples(self) -> World { + for (name, bytes) in fixture::documents() { + self.put(name, &bytes); + } + self.put("Garden plan.pdf", &fixture::garden_plan()); + self + } + + fn put(&self, rel: &str, bytes: &[u8]) { + let path = self.root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + + /// A call of `app`, from the foreground (`may_prompt`) or not, from the + /// isolate whose heap key is `isolate` (0: none). + fn call_full(&self, app: &str, method: &str, args: Json, may_prompt: bool, isolate: usize) -> Result { + let call = ServiceCall { app_id: app.into(), service: format!("pdf.{method}"), args, from_sheet: false, may_prompt, host_dir: self.root.join(".host") }; + serve(&self.areas, &call, isolate) + } + + fn call(&self, method: &str, args: Json) -> Result { + self.call_full(APP, method, args, true, 0) + } + + fn ok(&self, method: &str, args: Json) -> Json { + self.call(method, args.clone()).unwrap_or_else(|e| panic!("pdf.{method} {args}: {e}")) + } + + fn err(&self, method: &str, args: Json) -> String { + match self.call(method, args.clone()) { + Ok(answer) => panic!("pdf.{method} {args} answered {answer}"), + Err(e) => e, + } + } + + /// Open `path`: its handle. + fn open(&self, path: &str) -> String { + self.ok("open", json!({ "path": path }))["doc"].as_str().unwrap().to_string() + } +} + +/// `error` starts with the stable code `code`. +#[track_caller] +fn coded(error: &str, code: &str) { + assert!(error.starts_with(&format!("{code}: ")), "expected {code}: {error}"); +} + +/// A one-page PDF of `w` × `h` points with a line of text. +fn sized_pdf(w: f64, h: f64) -> Vec { + let content = "BT /F1 12 Tf 20 20 Td (A sized page) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + ]; + assemble(&objs) +} + +/// Number and cross-reference `objs` (object 1 the catalog). +fn assemble(objs: &[String]) -> Vec { + let mut pdf = b"%PDF-1.4\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend(format!("{} 0 obj\n{body}\nendobj\n", i + 1).bytes()); + } + let xref = pdf.len(); + pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).bytes()); + for off in &offsets { + pdf.extend(format!("{off:010} 00000 n \n").bytes()); + } + pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF", objs.len() + 1).bytes()); + pdf +} + +/// A form whose own scripts would each leave a mark if they ran: field +/// `a` has a keystroke and a format script that write `note`; `total` has +/// a calculate script (and is in the calculation order); `v` has a +/// validate script that refuses every value; a document script writes +/// `note`, and so does the document's open action. +fn scripted_form() -> Vec { + let widget = |name: &str, value: &str, y: u32, actions: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [20 {y} 180 {}] /P 3 0 R /F 4 /DA (/Helv 10 Tf 0 g){actions} >>", y + 20) + }; + let js = |source: &str| format!("<< /S /JavaScript /JS ({source}) >>"); + let page = "BT /Helv 12 Tf 20 280 Td (A scripted form) Tj ET"; + let objs = [ + // 1 catalog, 2 pages, 3 page + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 7 0 R] /CO [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >> /OpenAction 9 0 R /Names << /JavaScript 15 0 R >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /Helv 8 0 R >> >> /Annots [4 0 R 5 0 R 6 0 R 7 0 R] /Contents 16 0 R >>".to_string(), + // 4–7 the fields + widget("a", "1", 240, " /AA << /K 10 0 R /F 11 0 R >>"), + widget("total", "untouched", 200, " /AA << /C 12 0 R >>"), + widget("v", "", 160, " /AA << /V 13 0 R >>"), + widget("note", "quiet", 120, ""), + // 8 the font + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + // 9 the open action, 10–13 the field scripts, 14 the document script + js("this.getField(\"note\").value = \"opened\";"), + js("this.getField(\"note\").value = \"keystroke\";"), + js("event.value = \"formatted\"; this.getField(\"note\").value = \"format\";"), + js("event.value = \"calculated\";"), + js("event.rc = false;"), + js("this.getField(\"note\").value = \"document script\";"), + // 15 the document scripts' name tree, 16 the page's content + "<< /Names [(setup) 14 0 R] >>".to_string(), + format!("<< /Length {} >>\nstream\n{page}\nendstream", page.len()), + ]; + assemble(&objs) +} + +/// A field's value in `fields` (from pdf.fields). +fn value(fields: &Json, name: &str) -> Json { + fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).map(|f| f["value"].clone()).unwrap_or_else(|| panic!("no field {name}: {fields}")) +} + +/// A PDF that opens only with a password, made by the engine itself. +fn locked_pdf() -> Vec { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("plain.pdf"), tiny_pdf("A secret", "page two")).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let (doc, _) = open(&mut a, "plain.pdf").unwrap(); + run(&mut a, "doc_protect", &json!({ "doc": doc, "open_password": "correct horse" })).unwrap(); + run(&mut a, "doc_save", &json!({ "doc": doc, "path": "locked.pdf" })).unwrap(); + std::fs::read(dir.path().join("locked.pdf")).unwrap() +} + +// ------------------------------------------------------------ documents and reading + +#[test] +fn opening_reading_and_closing() { + let w = World::new().samples(); + let open = w.ok("open", json!({"path": "Quarterly report.pdf"})); + let doc = open["doc"].as_str().unwrap().to_string(); + assert!(!doc.is_empty() && doc.bytes().all(|b| b.is_ascii_hexdigit()), "{open}"); + assert_eq!(open["path"], "Quarterly report.pdf"); + assert_eq!(open["pages"], 4); + assert_eq!(open["sizes"], json!([[595.0, 842.0], [595.0, 842.0], [595.0, 842.0], [595.0, 842.0]])); + assert_eq!(open["title"], "Quarterly report - third quarter 2026"); + assert_eq!(open["outline"], json!([])); + assert_eq!((open["fields"].clone(), open["comments"].clone(), open["can_edit"].clone()), (json!(0), json!(0), json!(true)), "{open}"); + + assert_eq!(w.ok("state", json!({"doc": doc})), json!({"edited": false, "can_undo": false, "can_redo": false, "pages": 4})); + let info = w.ok("info", json!({"doc": doc})); + assert_eq!((info["file"].clone(), info["document"]["path"].clone(), info["document"]["pages"].clone()), (json!("Quarterly report.pdf"), json!("Quarterly report.pdf"), json!(4)), "{info}"); + let text = w.ok("text", json!({"doc": doc, "pages": [2]})); + assert_eq!(text["pages"][0]["page"], 2); + assert!(text["pages"][0]["text"].as_str().unwrap().contains("Summary"), "{text}"); + + // A page at screen resolution, into the render cache; again from it. + let page = w.ok("page", json!({"doc": doc, "page": 1})); + let rel = format!(".cache/pages/{doc}/1@96.png"); + assert_eq!(page, json!({"path": rel, "width": 794, "height": 1123, "dpi": 96})); + let file = w.root.join(&rel); + assert!(std::fs::read(&file).unwrap().starts_with(b"\x89PNG")); + let written = std::fs::metadata(&file).unwrap().modified().unwrap(); + std::thread::sleep(Duration::from_millis(20)); + assert_eq!(w.ok("page", json!({"doc": doc, "page": 1})), page); + assert_eq!(std::fs::metadata(&file).unwrap().modified().unwrap(), written, "served from the cache, not drawn again"); + let small = w.ok("page", json!({"doc": doc, "page": 2, "dpi": 36})); + assert_eq!(small, json!({"path": format!(".cache/pages/{doc}/2@36.png"), "width": 298, "height": 421, "dpi": 36})); + + let found = w.ok("find", json!({"doc": doc, "query": "revenue"})); + let total = found["total"].as_u64().unwrap(); + let matches = found["matches"].as_array().unwrap(); + assert!(total >= 4 && matches.len() as u64 == total, "{found}"); + for m in matches { + let rect = &m["rects"][0]; + assert_eq!(rect.as_array().unwrap().len(), 4, "{m}"); + assert!(rect[2].as_f64().unwrap() > 0.0 && rect[3].as_f64().unwrap() > 0.0, "[x, y, w, h]: {m}"); + let snippet = m["snippet"].as_str().unwrap(); + assert!(snippet.chars().count() <= 120, "{snippet}"); + let marked = snippet.split("[[").nth(1).and_then(|s| s.split("]]").next()).unwrap(); + assert_eq!(marked.to_lowercase(), "revenue", "{snippet}"); + } + assert_eq!(w.ok("find", json!({"doc": doc, "query": "revenue", "limit": 2}))["matches"].as_array().unwrap().len(), 2); + assert_eq!(w.ok("find", json!({"doc": doc, "query": "revenue", "limit": 2}))["total"], json!(total), "total counts past the limit"); + assert_eq!(w.ok("find", json!({"doc": doc, "query": "no such words here"})), json!({"total": 0, "matches": []})); + + let lines = w.ok("lines", json!({"doc": doc, "page": 2})); + let first = &lines["lines"][0]; + assert!(first["n"] == 1 && first["text"].is_string() && first["font"].is_string() && first["size"].is_number(), "{lines}"); + assert_eq!(first["box"].as_array().unwrap().len(), 4); + assert!(lines["lines"].as_array().unwrap().iter().any(|l| l["text"] == "Summary"), "{lines}"); + let para = &lines["paragraphs"][0]; + assert!(para["n"] == 1 && para["lines"].as_array().is_some_and(|l| !l.is_empty()) && para["box"].as_array().is_some(), "{lines}"); + + assert_eq!(w.ok("close", json!({"doc": doc})), json!({"closed": true})); + coded(&w.err("state", json!({"doc": doc})), "unknown_doc"); + assert!(!w.root.join(format!(".cache/pages/{doc}")).exists(), "a closed document's renders go"); +} + +#[test] +fn a_damaged_pdf_is_damaged_and_a_missing_one_not_found() { + let w = World::new().samples(); + let e = w.err("open", json!({"path": "Damaged scan.pdf"})); + coded(&e, "damaged"); + assert!(e.len() > "damaged: ".len() + 5, "the engine's reason follows: {e}"); + coded(&w.err("open", json!({"path": "Not here.pdf"})), "not_found"); + coded(&w.err("info", json!({"path": "Damaged scan.pdf"})), "damaged"); + coded(&w.err("open", json!({})), "invalid"); + assert_eq!(docs::open_count(), 0); +} + +#[test] +fn a_protected_pdf_is_refused_as_protected() { + let w = World::new().samples(); + w.put("locked.pdf", &locked_pdf()); + for (method, args) in [("open", json!({"path": "locked.pdf"})), ("info", json!({"path": "locked.pdf"})), ("text", json!({"path": "locked.pdf"}))] { + let e = w.err(method, args); + coded(&e, "protected"); + assert!(e.contains("password"), "{e}"); + } + let e = w.err("merge", json!({"paths": ["locked.pdf", "Board minutes.pdf"], "out": "m.pdf"})); + coded(&e, "protected"); + let doc = w.open("Board minutes.pdf"); + coded(&w.err("pages", json!({"doc": doc, "op": "insert_file", "path": "locked.pdf", "at": 1})), "protected"); + assert_eq!(w.ok("state", json!({"doc": doc}))["pages"], 2, "nothing was inserted"); +} + +// ------------------------------------------------------------ handles and the cap + +#[test] +fn an_unknown_or_foreign_handle_is_unknown() { + let w = World::new().samples(); + let doc = w.open("Board minutes.pdf"); + // Another app, in the same storage: the handle names nothing of its own. + let e = w.call_full("os.notes", "state", json!({"doc": doc}), true, 0).unwrap_err(); + coded(&e, "unknown_doc"); + // The same app in another storage scope (another account's folder). + let other = World::new().samples(); + coded(&other.err("state", json!({"doc": doc})), "unknown_doc"); + coded(&other.err("close", json!({"doc": doc, "discard": true})), "unknown_doc"); + for bad in [json!("0000"), json!("not a handle"), json!("../x"), json!(7), json!(true), json!(["x"])] { + coded(&w.err("state", json!({"doc": bad})), "unknown_doc"); + } + coded(&w.err("state", json!({})), "invalid"); + // Every method that takes a document refuses a foreign one alike. + let foreign = |method: &str, args: Json| { + let e = other.err(method, args); + coded(&e, "unknown_doc"); + }; + foreign("info", json!({"doc": doc})); + foreign("text", json!({"doc": doc})); + foreign("page", json!({"doc": doc, "page": 1})); + foreign("find", json!({"doc": doc, "query": "garden"})); + foreign("lines", json!({"doc": doc, "page": 1})); + foreign("comments", json!({"doc": doc})); + foreign("comment", json!({"doc": doc, "op": "add", "page": 1, "type": "note", "at": [10, 10]})); + foreign("fields", json!({"doc": doc})); + foreign("fill", json!({"doc": doc, "values": {"x": "y"}})); + foreign("fill_sign", json!({"doc": doc, "page": 1, "kind": "check", "at": [10, 10]})); + foreign("pages", json!({"doc": doc, "op": "rotate", "pages": [1], "angle": 90})); + foreign("edit_text", json!({"doc": doc, "page": 1, "line": 1, "text": "x"})); + foreign("undo", json!({"doc": doc})); + foreign("redo", json!({"doc": doc})); + foreign("save", json!({"doc": doc})); + foreign("export", json!({"doc": doc, "kind": "text", "out": "x.txt"})); + // Still open for its own caller; once closed, unknown to it too. + assert_eq!(w.ok("state", json!({"doc": doc}))["pages"], 2); + w.ok("close", json!({"doc": doc})); + coded(&w.err("state", json!({"doc": doc})), "unknown_doc"); +} + +#[test] +fn the_ninth_open_is_refused() { + let w = World::new().samples(); + let docs: Vec = (0..docs::MAX_OPEN).map(|_| w.open("Board minutes.pdf")).collect(); + let mut unique = docs.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), 8, "a handle per open: {docs:?}"); + let e = w.err("open", json!({"path": "Field guide.pdf"})); + coded(&e, "too_many_open"); + // The cap is per caller: another app, and another scope, still open theirs. + assert!(w.call_full("os.notes", "open", json!({"path": "Board minutes.pdf"}), true, 0).is_ok()); + let other = World::new().samples(); + other.open("Board minutes.pdf"); + w.ok("close", json!({"doc": docs[3]})); + w.open("Field guide.pdf"); + coded(&w.err("open", json!({"path": "Field guide.pdf"})), "too_many_open"); +} + +/// A document opened from an app's isolate lives while that isolate's +/// storage scope does: when the app closes (App Hub's card shutdown clears +/// the isolate's host tag), the next call of anyone releases it. +#[test] +fn a_document_goes_when_its_apps_storage_scope_does() { + use makepad_widgets::*; + let w = World::new().samples(); + let mut cx = Cx::new(Box::new(|_, _| {})); + let mut card = cx.with_vm(|vm| { + makepad_widgets::script_mod(vm); + let value = vm.eval(script! {use mod.widgets.* Splash{}}); + Splash::script_from_value(vm, value) + }); + card.set_host_tag(&mut cx, Some(APP.into())); + card.set_sandbox_dir(&mut cx, Some(w.root.clone())); + card.set_text(&mut cx, "View {}"); + let heap = card.isolate_heap_key(&mut cx).unwrap(); + let opened = w.call_full(APP, "open", json!({"path": "Board minutes.pdf"}), true, heap).unwrap(); + let doc = opened["doc"].clone(); + let unbound = w.open("Field guide.pdf"); + assert_eq!(w.call_full(APP, "state", json!({"doc": doc}), true, heap).unwrap()["pages"], 2); + // Another surface of the same app and storage reaches it by its handle. + assert_eq!(w.call_full(APP, "state", json!({"doc": doc}), false, 0).unwrap()["pages"], 2); + // Long idle is no reason to close a document an open app holds. + docs::sweep_at(Instant::now() + docs::IDLE + Duration::from_secs(60)); + assert_eq!(w.call_full(APP, "state", json!({"doc": doc}), true, heap).unwrap()["pages"], 2); + // The app closes. + card.set_host_tag(&mut cx, None); + coded(&w.call_full(APP, "state", json!({"doc": doc}), true, 0).unwrap_err(), "unknown_doc"); + // The document no isolate held went with the idle sweep above. + coded(&w.err("state", json!({"doc": unbound})), "unknown_doc"); + assert_eq!(docs::open_count(), 0); + // A scope that changes (another account's storage) is gone too. + card.set_host_tag(&mut cx, Some(APP.into())); + let opened = w.call_full(APP, "open", json!({"path": "Board minutes.pdf"}), true, heap).unwrap(); + let elsewhere = tempfile::tempdir().unwrap(); + card.set_sandbox_dir(&mut cx, Some(elsewhere.path().to_path_buf())); + coded(&w.call_full(APP, "state", json!({"doc": opened["doc"]}), true, heap).unwrap_err(), "unknown_doc"); +} + +#[test] +fn a_document_no_isolate_holds_goes_after_it_is_idle() { + let w = World::new().samples(); + let doc = w.open("Board minutes.pdf"); + docs::sweep_at(Instant::now() + docs::IDLE - Duration::from_secs(30)); + assert_eq!(w.ok("state", json!({"doc": doc}))["pages"], 2, "not idle long enough"); + docs::sweep_at(Instant::now() + docs::IDLE + Duration::from_secs(1)); + coded(&w.err("state", json!({"doc": doc})), "unknown_doc"); +} + +// ------------------------------------------------------------ comments, fields, Fill & Sign + +#[test] +fn comments_threads_and_status() { + let w = World::new().samples(); + let doc = w.open("Quarterly report.pdf"); + let hit = &w.ok("find", json!({"doc": doc, "query": "closed ahead of plan"}))["matches"][0]; + let highlight = w.ok("comment", json!({"doc": doc, "op": "add", "page": hit["page"], "type": "highlight", "rects": hit["rects"], "color": "yellow", "text": "Good news", "author": "Ana"})); + let highlight = highlight["id"].as_str().unwrap().to_string(); + let note = w.ok("comment", json!({"doc": doc, "op": "add", "page": 2, "type": "note", "at": [60, 120], "text": "Check the figures", "author": "Ana"}))["id"].clone(); + let note = note.as_str().unwrap().to_string(); + let boxed = w.ok("comment", json!({"doc": doc, "op": "add", "page": 3, "type": "textbox", "rects": [[60, 100, 200, 40]], "text": "Regions"}))["id"].clone(); + for kind in ["underline", "strikeout"] { + w.ok("comment", json!({"doc": doc, "op": "add", "page": 4, "type": kind, "rects": [[56, 90, 120, 14], [56, 110, 80, 14]]})); + } + let reply = w.ok("comment", json!({"doc": doc, "op": "reply", "id": note, "text": "Done", "author": "Tom"}))["id"].clone(); + assert!(reply.is_string() && reply != json!(note), "{reply}"); + assert_eq!(w.ok("comment", json!({"doc": doc, "op": "status", "id": note, "status": "accepted"})), json!({"id": note})); + assert_eq!(w.ok("comment", json!({"doc": doc, "op": "edit", "id": note, "text": "Check the figures again", "color": "#FF0000"})), json!({"id": note})); + assert_eq!(w.ok("comment", json!({"doc": doc, "op": "delete", "id": boxed})), json!({"deleted": true})); + + let list = w.ok("comments", json!({"doc": doc})); + let all = list["comments"].as_array().unwrap(); + assert_eq!(all.len(), 4, "{list}"); + let by_id = |id: &str| all.iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("no comment {id}: {list}")); + let h = by_id(&highlight); + assert_eq!((h["type"].clone(), h["author"].clone(), h["text"].clone(), h["status"].clone()), (json!("highlight"), json!("Ana"), json!("Good news"), json!("none")), "{h}"); + assert_eq!(h["page"], hit["page"]); + assert_eq!(h["rects"][0].as_array().unwrap().len(), 4, "{h}"); + assert!(h["date"].as_str().is_some_and(|d| d.len() == 16 && d.as_bytes()[4] == b'-' && d.as_bytes()[10] == b'T'), "ISO 8601: {h}"); + let n = by_id(¬e); + assert_eq!((n["type"].clone(), n["text"].clone(), n["color"].clone(), n["status"].clone()), (json!("note"), json!("Check the figures again"), json!("#FF0000"), json!("accepted")), "{n}"); + let replies = n["replies"].as_array().unwrap(); + assert_eq!(replies.len(), 1, "a status is not a reply: {n}"); + assert_eq!((replies[0]["id"].clone(), replies[0]["author"].clone(), replies[0]["text"].clone()), (reply, json!("Tom"), json!("Done"))); + assert!(all.iter().any(|c| c["type"] == "underline") && all.iter().any(|c| c["type"] == "strikeout"), "{list}"); + + for bad in [ + json!({"doc": doc, "op": "add", "page": 1, "type": "stamp", "at": [10, 10]}), + json!({"doc": doc, "op": "add", "page": 1, "type": "highlight"}), + json!({"doc": doc, "op": "add", "page": 1, "type": "note"}), + json!({"doc": doc, "op": "add", "page": 1, "type": "highlight", "rects": [[0, 0, 0, 10]]}), + json!({"doc": doc, "op": "add", "page": 1, "type": "textbox", "rects": [[0, 0, 10, 10], [20, 20, 10, 10]]}), + json!({"doc": doc, "op": "status", "id": note, "status": "approved"}), + json!({"doc": doc, "op": "edit", "id": note}), + json!({"doc": doc, "op": "nudge", "id": note}), + ] { + coded(&w.err("comment", bad), "invalid"); + } + coded(&w.err("comment", json!({"doc": doc, "op": "add", "page": 9, "type": "note", "at": [10, 10]})), "invalid"); + coded(&w.err("comment", json!({"doc": doc, "op": "delete", "id": "no-such-comment"})), "invalid"); + assert_eq!(w.ok("comments", json!({"doc": doc}))["comments"].as_array().unwrap().len(), 4, "refusals changed nothing"); +} + +/// `comment_add` is `file` because of its attachment type: the service +/// gives it the five comment types and the reviewed parameters only, and +/// refuses a file (or any other unreviewed) parameter before the engine +/// sees the call. +#[test] +fn comment_add_takes_no_file_parameter() { + let w = World::new().samples(); + let doc = w.open("Board minutes.pdf"); + for (extra, value) in [("path", json!("Field guide.pdf")), ("file", json!("Field guide.pdf")), ("image", json!("x.png")), ("stamp", json!("approved")), ("find", json!("Garden")), ("quads", json!([[0, 0, 1, 0, 0, 1, 1, 1]])), ("icon", json!("Paperclip"))] { + let mut args = json!({"doc": doc, "op": "add", "page": 1, "type": "note", "at": [20, 20], "text": "x"}); + args[extra] = value; + let e = w.err("comment", args); + coded(&e, "invalid"); + assert!(e.contains(&format!("`{extra}` is not one of them")), "{e}"); + } + let e = w.err("comment", json!({"doc": doc, "op": "add", "page": 1, "type": "attachment", "at": [20, 20], "path": "Field guide.pdf"})); + coded(&e, "invalid"); + let e = w.err("comment", json!({"doc": doc, "op": "add", "page": 1, "type": "attachment", "at": [20, 20]})); + coded(&e, "invalid"); + assert!(e.contains("highlight, underline, strikeout, note, textbox"), "{e}"); + assert_eq!(w.ok("comments", json!({"doc": doc})), json!({"comments": []}), "nothing reached the document"); + assert_eq!(w.ok("state", json!({"doc": doc}))["edited"], false); +} + +#[test] +fn fields_fill_and_fill_and_sign() { + let w = World::new().samples(); + w.put("form.pdf", &scripted_form()); + let doc = w.open("form.pdf"); + let fields = w.ok("fields", json!({"doc": doc})); + let a = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "a").unwrap().clone(); + assert_eq!((a["type"].clone(), a["value"].clone(), a["required"].clone(), a["page"].clone(), a["options"].clone()), (json!("text"), json!("1"), json!(false), json!(1), json!([])), "{a}"); + assert_eq!(a["rect"], json!([20.0, 40.0, 160.0, 20.0]), "[x, y, w, h] from the page's top-left: {a}"); + assert_eq!(w.ok("fill", json!({"doc": doc, "values": {"a": "7", "note": "hello"}})), json!({"filled": 2})); + let fields = w.ok("fields", json!({"doc": doc})); + assert_eq!((value(&fields, "a"), value(&fields, "note")), (json!("7"), json!("hello"))); + let state = w.ok("state", json!({"doc": doc})); + assert_eq!((state["edited"].clone(), state["can_undo"].clone()), (json!(true), json!(true)), "one undo step: {state}"); + w.ok("undo", json!({"doc": doc})); + let fields = w.ok("fields", json!({"doc": doc})); + assert_eq!((value(&fields, "a"), value(&fields, "note")), (json!("1"), json!("quiet")), "both values in one step"); + coded(&w.err("fill", json!({"doc": doc, "values": {"no such field": "x"}})), "invalid"); + coded(&w.err("fill", json!({"doc": doc, "values": {}})), "invalid"); + coded(&w.err("fill", json!({"doc": doc, "values": {"a": {"nested": 1}}})), "invalid"); + + // Fill & Sign on a form with no fields. + let lease = w.open("Apartment lease.pdf"); + let last = w.ok("state", json!({"doc": lease}))["pages"].as_u64().unwrap(); + for (kind, text) in [("text", Some("Sam Rivera")), ("date", None), ("initials", Some("SR")), ("check", None), ("cross", None)] { + let mut args = json!({"doc": lease, "page": last, "kind": kind, "at": [60, 300]}); + if let Some(text) = text { + args["text"] = json!(text); + } + assert_eq!(w.ok("fill_sign", args), json!({"added": true}), "{kind}"); + } + assert_eq!(w.ok("comments", json!({"doc": lease}))["comments"].as_array().unwrap().len(), 5, "Fill & Sign marks are annotations"); + coded(&w.err("fill_sign", json!({"doc": lease, "page": 1, "kind": "signature", "at": [60, 300], "text": "Sam"})), "invalid"); + coded(&w.err("fill_sign", json!({"doc": lease, "page": 1, "kind": "text", "at": [60, 300]})), "invalid"); + coded(&w.err("fill_sign", json!({"doc": lease, "page": 1, "kind": "check"})), "invalid"); +} + +/// `doc_open` and `form_fill` are `code`: with JavaScript on, the engine +/// runs a form's scripts. Every service session has it off, so a form's +/// own scripts never run: the keystroke, format, calculate and refusing +/// validate scripts, the document script and the open action of +/// [`scripted_form`] leave no mark through any method, where the engine's +/// default session (JavaScript on) runs the calculate and validate +/// scripts. An XFA form's initialize and calculate scripts stay off too. +#[test] +fn a_forms_own_scripts_never_run() { + let w = World::new(); + w.put("form.pdf", &scripted_form()); + // The engine's default session: the scripts are live. + let mut on = Automation::new().with_root(&w.root).unwrap(); + assert!(on.session().javascript()); + let (d, _) = open(&mut on, "form.pdf").unwrap(); + run(&mut on, "form_fill", &json!({"doc": d, "values": {"a": "5"}})).unwrap(); + let live = run(&mut on, "form_fields", &json!({"doc": d})).unwrap(); + assert_eq!(value(&live, "total"), json!("calculated"), "the calculate script runs with JavaScript on: {live}"); + assert_ne!(value(&live, "note"), json!("quiet"), "a script that writes another field runs with JavaScript on: {live}"); + assert!(run(&mut on, "form_fill", &json!({"doc": d, "values": {"v": "x"}})).is_err(), "the validate script refuses every value with JavaScript on"); + + // Through the service: nothing runs, whatever the method. + let doc = w.open("form.pdf"); + let quiet = |fields: &Json| { + assert_eq!(value(fields, "note"), json!("quiet"), "no open action, document, keystroke or format script ran: {fields}"); + assert_eq!(value(fields, "total"), json!("untouched"), "no calculate script ran: {fields}"); + }; + quiet(&w.ok("fields", json!({"doc": doc}))); + assert_eq!(w.ok("fill", json!({"doc": doc, "values": {"a": "5", "v": "x"}})), json!({"filled": 2}), "no validate script refused the value"); + let fields = w.ok("fields", json!({"doc": doc})); + quiet(&fields); + assert_eq!((value(&fields, "a"), value(&fields, "v")), (json!("5"), json!("x"))); + w.ok("page", json!({"doc": doc, "page": 1})); + w.ok("find", json!({"doc": doc, "query": "scripted"})); + w.ok("lines", json!({"doc": doc, "page": 1})); + w.ok("text", json!({"doc": doc})); + w.ok("info", json!({"doc": doc})); + quiet(&w.ok("fields", json!({"doc": doc}))); + // Saved and opened again, still as filled. + w.ok("save", json!({"doc": doc})); + w.ok("close", json!({"doc": doc})); + let again = w.open("form.pdf"); + let fields = w.ok("fields", json!({"doc": again})); + quiet(&fields); + assert_eq!(value(&fields, "a"), json!("5")); + + // An XFA form: its initialize script would set qty to 2 on opening, and + // its calculate script total = qty × price. + w.put("xfa.pdf", &pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); + let xfa = w.open("xfa.pdf"); + assert_ne!(value(&w.ok("fields", json!({"doc": xfa})), "qty"), json!("2"), "no initialize script ran"); + w.ok("fill", json!({"doc": xfa, "values": {"qty": "3"}})); + let fields = w.ok("fields", json!({"doc": xfa})); + assert_eq!(value(&fields, "qty"), json!("3")); + assert_ne!(value(&fields, "total"), json!("15"), "no calculate script ran: {fields}"); +} + +// ------------------------------------------------------------ pages, edits, history + +#[test] +fn page_operations_and_text_edits() { + let w = World::new().samples(); + let doc = w.open("Quarterly report.pdf"); + let pages = |args: Json| { + let mut args = args; + args["doc"] = json!(doc); + w.ok("pages", args)["pages"].as_u64().unwrap() + }; + assert_eq!(pages(json!({"op": "rotate", "pages": [1], "angle": 90})), 4); + assert_eq!(w.ok("page", json!({"doc": doc, "page": 1, "dpi": 24}))["width"], 281, "page 1 is landscape now"); + assert_eq!(w.ok("info", json!({"doc": doc}))["pages"][0]["width"], json!(842.0)); + assert_eq!(pages(json!({"op": "rotate", "pages": [1], "angle": -90})), 4); + assert_eq!(pages(json!({"op": "duplicate", "pages": [2]})), 5); + assert_eq!(pages(json!({"op": "move", "pages": [3], "to": 1})), 5); + assert!(w.ok("text", json!({"doc": doc, "pages": [1]}))["pages"][0]["text"].as_str().unwrap().contains("Summary"), "the copy moved to the front"); + assert_eq!(pages(json!({"op": "delete", "pages": [1]})), 4); + assert_eq!(pages(json!({"op": "insert_file", "path": "Garden plan.pdf", "at": 5})), 7); + assert_eq!(pages(json!({"op": "insert_file", "path": "Board minutes.pdf", "at": 1, "pages": [2]})), 8); + assert_eq!(w.ok("pages", json!({"doc": doc, "op": "extract", "pages": [6, 7, 8], "out": "garden again.pdf"})), json!({"path": "garden again.pdf"})); + assert_eq!(w.ok("info", json!({"path": "garden again.pdf"}))["document"]["pages"], 3); + coded(&w.err("pages", json!({"doc": doc, "op": "delete", "pages": [1, 2, 3, 4, 5, 6, 7, 8]})), "invalid"); + coded(&w.err("pages", json!({"doc": doc, "op": "rotate", "pages": [1], "angle": 45})), "invalid"); + coded(&w.err("pages", json!({"doc": doc, "op": "move", "pages": [99], "to": 1})), "invalid"); + coded(&w.err("pages", json!({"doc": doc, "op": "shuffle", "pages": [1]})), "invalid"); + let many: Vec = (1..=513).collect(); + coded(&w.err("pages", json!({"doc": doc, "op": "duplicate", "pages": many})), "too_large"); + assert_eq!(w.ok("state", json!({"doc": doc}))["pages"], 8); + + // A line, then a paragraph, edited in place. + let lines = w.ok("lines", json!({"doc": doc, "page": 3})); + let heading = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == "Summary").unwrap_or_else(|| panic!("{lines}"))["n"].clone(); + assert_eq!(w.ok("edit_text", json!({"doc": doc, "page": 3, "line": heading, "text": "Overview"})), json!({"edited": true})); + let lines = w.ok("lines", json!({"doc": doc, "page": 3})); + assert!(lines["lines"].as_array().unwrap().iter().any(|l| l["text"] == "Overview"), "{lines}"); + let para = lines["paragraphs"].as_array().unwrap().iter().find(|p| p["text"].as_str().unwrap().starts_with("Revenue reached")).unwrap()["n"].clone(); + w.ok("edit_text", json!({"doc": doc, "page": 3, "paragraph": para, "text": "Revenue reached 4.3 million dollars."})); + assert!(w.ok("text", json!({"doc": doc, "pages": [3]}))["pages"][0]["text"].as_str().unwrap().contains("4.3 million")); + coded(&w.err("edit_text", json!({"doc": doc, "page": 3, "line": 1, "paragraph": 1, "text": "x"})), "invalid"); + coded(&w.err("edit_text", json!({"doc": doc, "page": 3, "text": "x"})), "invalid"); + coded(&w.err("edit_text", json!({"doc": doc, "page": 3, "line": 999, "text": "x"})), "invalid"); +} + +#[test] +fn undo_and_redo_across_calls() { + let w = World::new().samples(); + let doc = w.open("Board minutes.pdf"); + coded(&w.err("undo", json!({"doc": doc})), "invalid"); + w.ok("comment", json!({"doc": doc, "op": "add", "page": 1, "type": "note", "at": [40, 40], "text": "one"})); + w.ok("pages", json!({"doc": doc, "op": "rotate", "pages": [2], "angle": 180})); + assert_eq!(w.ok("state", json!({"doc": doc})), json!({"edited": true, "can_undo": true, "can_redo": false, "pages": 2})); + assert_eq!(w.ok("undo", json!({"doc": doc})), json!({"edited": true, "can_undo": true, "can_redo": true})); + assert_eq!(w.ok("info", json!({"doc": doc}))["pages"][1]["rotation"], 0, "the rotation was undone"); + assert_eq!(w.ok("undo", json!({"doc": doc})), json!({"edited": true, "can_undo": false, "can_redo": true})); + assert_eq!(w.ok("comments", json!({"doc": doc}))["comments"], json!([])); + assert_eq!(w.ok("redo", json!({"doc": doc}))["can_redo"], true); + assert_eq!(w.ok("comments", json!({"doc": doc}))["comments"].as_array().unwrap().len(), 1); + assert_eq!(w.ok("redo", json!({"doc": doc})), json!({"edited": true, "can_undo": true, "can_redo": false})); + assert_eq!(w.ok("info", json!({"doc": doc}))["pages"][1]["rotation"], 180); + coded(&w.err("redo", json!({"doc": doc})), "invalid"); + // A render after an undo shows the document as it is now. + let before = w.ok("page", json!({"doc": doc, "page": 2, "dpi": 24})); + let file = w.root.join(before["path"].as_str().unwrap()); + let upside_down = std::fs::read(&file).unwrap(); + w.ok("undo", json!({"doc": doc})); + let after = w.ok("page", json!({"doc": doc, "page": 2, "dpi": 24})); + assert_eq!(before["path"], after["path"]); + assert_ne!(std::fs::read(&file).unwrap(), upside_down, "drawn again without the rotation"); + // Closing with edits asks first. + coded(&w.err("close", json!({"doc": doc})), "unsaved"); + assert_eq!(w.ok("close", json!({"doc": doc, "discard": true})), json!({"closed": true})); +} + +// ------------------------------------------------------------ saving and writing + +#[test] +fn saving_incrementally_and_as_a_new_file() { + let w = World::new().samples(); + let original = std::fs::read(w.root.join("Board minutes.pdf")).unwrap(); + let doc = w.open("Board minutes.pdf"); + w.ok("comment", json!({"doc": doc, "op": "add", "page": 1, "type": "note", "at": [40, 40], "text": "kept"})); + let saved = w.ok("save", json!({"doc": doc})); + assert_eq!(saved["path"], "Board minutes.pdf"); + let now = std::fs::read(w.root.join("Board minutes.pdf")).unwrap(); + assert_eq!(saved["bytes"], json!(now.len())); + assert!(now.len() > original.len() && now.starts_with(&original), "an incremental update of the file"); + assert_eq!(w.ok("state", json!({"doc": doc}))["edited"], false); + w.ok("close", json!({"doc": doc})); + // The comment is in the file. + let doc = w.open("Board minutes.pdf"); + assert_eq!(w.ok("comments", json!({"doc": doc}))["comments"][0]["text"], "kept"); + // Save as: a new file, which the document is from then on. + w.ok("pages", json!({"doc": doc, "op": "rotate", "pages": [1], "angle": 90})); + assert_eq!(w.ok("save", json!({"doc": doc, "path": "copies/minutes rotated.pdf"}))["path"], "copies/minutes rotated.pdf"); + assert_eq!(std::fs::read(w.root.join("Board minutes.pdf")).unwrap(), now, "the original is untouched"); + assert_eq!(w.ok("info", json!({"doc": doc}))["file"], "copies/minutes rotated.pdf"); + w.ok("pages", json!({"doc": doc, "op": "delete", "pages": [2]})); + w.ok("save", json!({"doc": doc})); + assert_eq!(w.ok("info", json!({"path": "copies/minutes rotated.pdf"}))["document"]["pages"], 1, "the next save went to the new file"); + assert_eq!(std::fs::read(w.root.join("Board minutes.pdf")).unwrap(), now); + assert!(no_staging_left(&w.root)); +} + +/// A full save goes to a new file and never over an existing one, from the +/// foreground too; an incremental save over the document's own file needs +/// the foreground; extract and export write new files only. +#[test] +fn a_full_save_never_replaces_a_file() { + let w = World::new().samples(); + w.put("taken.pdf", b"keep me"); + let doc = w.open("Board minutes.pdf"); + w.ok("comment", json!({"doc": doc, "op": "add", "page": 1, "type": "note", "at": [40, 40], "text": "x"})); + for out in ["taken.pdf", "Board minutes.pdf", "Field guide.pdf"] { + let e = w.err("save", json!({"doc": doc, "path": out})); + coded(&e, "invalid"); + assert!(e.contains("already exists"), "{e}"); + } + assert_eq!(std::fs::read(w.root.join("taken.pdf")).unwrap(), b"keep me"); + assert_eq!(w.ok("state", json!({"doc": doc}))["edited"], true, "still unsaved"); + coded(&w.err("pages", json!({"doc": doc, "op": "extract", "pages": [1], "out": "taken.pdf"})), "invalid"); + w.put("pages/Board minutes_page_2.png", b"theirs"); + coded(&w.err("export", json!({"doc": doc, "kind": "images", "out_dir": "pages", "dpi": 24})), "invalid"); + assert!(!w.root.join("pages/Board minutes_page_1.png").exists(), "all or nothing"); + assert_eq!(std::fs::read(w.root.join("pages/Board minutes_page_2.png")).unwrap(), b"theirs"); + coded(&w.err("export", json!({"doc": doc, "kind": "text", "out": "taken.pdf"})), "invalid"); + assert_eq!(std::fs::read(w.root.join("taken.pdf")).unwrap(), b"keep me"); + // A surface in the background may not replace the document's own file. + let before = std::fs::read(w.root.join("Board minutes.pdf")).unwrap(); + let e = w.call_full(APP, "save", json!({"doc": doc}), false, 0).unwrap_err(); + coded(&e, "invalid"); + assert_eq!(std::fs::read(w.root.join("Board minutes.pdf")).unwrap(), before); + assert!(w.call_full(APP, "save", json!({"doc": doc, "path": "from the background.pdf"}), false, 0).is_ok(), "a new file it may write"); + assert!(no_staging_left(&w.root)); +} + +#[test] +fn exports_and_merges_with_ranges() { + let w = World::new().samples(); + let doc = w.open("Quarterly report.pdf"); + let images = w.ok("export", json!({"doc": doc, "kind": "images", "out_dir": "exports", "dpi": 24, "pages": [1, 3]})); + assert_eq!(images, json!({"paths": ["exports/Quarterly report_page_1.png", "exports/Quarterly report_page_3.png"]})); + for p in images["paths"].as_array().unwrap() { + assert!(std::fs::read(w.root.join(p.as_str().unwrap())).unwrap().starts_with(b"\x89PNG"), "{p}"); + } + let all = w.ok("export", json!({"doc": doc, "kind": "images", "out_dir": "every page", "dpi": 24})); + assert_eq!(all["paths"].as_array().unwrap().len(), 4); + assert_eq!(w.ok("export", json!({"doc": doc, "kind": "text", "out": "exports/report.txt"})), json!({"paths": ["exports/report.txt"]})); + assert!(std::fs::read_to_string(w.root.join("exports/report.txt")).unwrap().contains("Regional results")); + coded(&w.err("export", json!({"doc": doc, "kind": "images", "out_dir": "x", "dpi": 301})), "invalid"); + coded(&w.err("export", json!({"doc": doc, "kind": "images", "out_dir": "x", "format": "jpeg"})), "invalid"); + coded(&w.err("export", json!({"doc": doc, "kind": "office", "out": "x.docx"})), "invalid"); + + // Combine with page ranges, a person's dash included. + let merged = w.ok("merge", json!({"paths": [{"path": "Quarterly report.pdf", "pages": "1–2"}, "Board minutes.pdf", {"path": "Garden plan.pdf", "pages": "3"}], "out": "mix.pdf"})); + assert_eq!(merged["out"], "mix.pdf"); + assert_eq!(w.ok("info", json!({"path": "mix.pdf"}))["document"]["pages"], 5); + w.ok("merge", json!({"paths": [{"path": "Field guide.pdf", "pages": "all"}, {"path": "Board minutes.pdf"}], "out": "mix2.pdf"})); + assert_eq!(w.ok("info", json!({"path": "mix2.pdf"}))["document"]["pages"], 9); + coded(&w.err("merge", json!({"paths": [{"path": "Field guide.pdf", "pages": "1; rm"}, "Board minutes.pdf"], "out": "m3.pdf"})), "invalid"); + coded(&w.err("merge", json!({"paths": [{"path": "Field guide.pdf", "range": "1"}, "Board minutes.pdf"], "out": "m3.pdf"})), "invalid"); + assert!(no_staging_left(&w.root)); +} + +#[test] +fn paths_never_leave_the_storage() { + let w = World::new().samples(); + std::fs::write(w.root.parent().unwrap().join("beside.pdf"), tiny_pdf("outside", "the storage")).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(w.root.parent().unwrap(), w.root.join("up")).unwrap(); + let doc = w.open("Board minutes.pdf"); + for bad in ["../beside.pdf", "/etc/hosts", "a/../../beside.pdf", "up/beside.pdf", "./Board minutes.pdf"] { + if bad.starts_with("up/") && !cfg!(unix) { + continue; + } + coded(&w.err("open", json!({"path": bad})), "invalid"); + coded(&w.err("pages", json!({"doc": doc, "op": "insert_file", "path": bad, "at": 1})), "invalid"); + coded(&w.err("pages", json!({"doc": doc, "op": "extract", "pages": [1], "out": bad})), "invalid"); + coded(&w.err("save", json!({"doc": doc, "path": bad})), "invalid"); + coded(&w.err("export", json!({"doc": doc, "kind": "text", "out": bad})), "invalid"); + coded(&w.err("export", json!({"doc": doc, "kind": "images", "out_dir": bad, "dpi": 24})), "invalid"); + coded(&w.err("merge", json!({"paths": [{"path": bad}, "Board minutes.pdf"], "out": "m.pdf"})), "invalid"); + } + let parent = w.root.parent().unwrap(); + let names: Vec = std::fs::read_dir(parent).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(names.len(), 2, "nothing landed beside the storage: {names:?}"); + assert_eq!(w.ok("state", json!({"doc": doc}))["pages"], 2); +} + +// ------------------------------------------------------------ caps and the render cache + +#[test] +fn dpi_and_megapixel_caps() { + let w = World::new().samples(); + w.put("poster.pdf", &sized_pdf(5000.0, 5000.0)); + w.put("tiny.pdf", &tiny_pdf("a", "b")); + let doc = w.open("tiny.pdf"); + for dpi in [23, 301, 600, 0] { + coded(&w.err("page", json!({"doc": doc, "page": 1, "dpi": dpi})), "invalid"); + } + coded(&w.err("page", json!({"doc": doc, "page": 1, "dpi": "high"})), "invalid"); + assert_eq!(w.ok("page", json!({"doc": doc, "page": 1}))["dpi"], 96, "96 by default"); + assert_eq!(w.ok("page", json!({"doc": doc, "page": 1, "dpi": 24}))["width"], 67); + assert_eq!(w.ok("page", json!({"doc": doc, "page": 1, "dpi": 300}))["width"], 834); + assert_eq!(w.ok("page", json!({"doc": doc, "page": 1, "dpi": 99.6}))["dpi"], 100, "rounded to a whole dpi"); + coded(&w.err("page", json!({"doc": doc, "page": 3})), "invalid"); + coded(&w.err("page", json!({"doc": doc, "page": 0})), "invalid"); + // 5000 pt a side: 16 MP is about 57 dpi at that size. + let poster = w.open("poster.pdf"); + let e = w.err("page", json!({"doc": poster, "page": 1, "dpi": 96})); + coded(&e, "too_large"); + assert!(e.contains("megapixels"), "{e}"); + assert_eq!(w.ok("page", json!({"doc": poster, "page": 1, "dpi": 57}))["width"], 3959); + coded(&w.err("page", json!({"doc": poster, "page": 1, "dpi": 58})), "too_large"); + coded(&w.err("export", json!({"doc": poster, "kind": "images", "out_dir": "big", "dpi": 96})), "too_large"); + assert!(!w.root.join("big").exists()); + // find's limit. + coded(&w.err("find", json!({"doc": doc, "query": "a", "limit": 501})), "invalid"); + coded(&w.err("find", json!({"doc": doc, "query": "a", "limit": 0})), "invalid"); + coded(&w.err("find", json!({"doc": doc, "query": " "})), "invalid"); + w.ok("find", json!({"doc": doc, "query": "a", "limit": 500})); + // 512 pages per call. + let many: Vec = (1..=513).collect(); + coded(&w.err("text", json!({"doc": doc, "pages": many})), "too_large"); + coded(&w.err("export", json!({"doc": doc, "kind": "text", "out": "t.txt", "pages": many})), "too_large"); +} + +/// The cache keeps at most 64 renders: the oldest go first. +#[test] +fn the_render_cache_keeps_to_its_file_cap() { + let w = World::new(); + w.put("tiny.pdf", &tiny_pdf("a", "b")); + let doc = w.open("tiny.pdf"); + let mut written = Vec::new(); + for dpi in (24..=300).step_by(8) { + for page in [1, 2] { + written.push(w.ok("page", json!({"doc": doc, "page": page, "dpi": dpi}))["path"].as_str().unwrap().to_string()); + } + } + assert!(written.len() > cache::MAX_FILES, "{} renders", written.len()); + let kept: Vec<&String> = written.iter().filter(|p| w.root.join(p).exists()).collect(); + assert_eq!(kept.len(), cache::MAX_FILES); + let gone = written.len() - cache::MAX_FILES; + assert!(written[..gone].iter().all(|p| !w.root.join(p).exists()), "the oldest went first"); + assert!(written[gone..].iter().all(|p| w.root.join(p).exists())); + // An evicted render is drawn again when asked for. + let again = w.ok("page", json!({"doc": doc, "page": 1, "dpi": 24})); + assert!(w.root.join(again["path"].as_str().unwrap()).exists()); +} + +/// The cache keeps at most 16 MiB of renders: the oldest go first. +#[test] +fn the_render_cache_keeps_to_its_byte_cap() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join(".cache/pages"); + let old = root.join("aaaa"); + std::fs::create_dir_all(&old).unwrap(); + let mib = vec![0u8; 1 << 20]; + let base = std::time::SystemTime::now() - Duration::from_secs(3600); + for i in 0..17u64 { + let path = old.join(format!("{i}@96.png")); + std::fs::write(&path, &mib).unwrap(); + std::fs::File::options().write(true).open(&path).unwrap().set_modified(base + Duration::from_secs(i)).unwrap(); + } + let keep = root.join("bbbb/1@96.png"); + cache::evict(&root, &keep, 1 << 20, cache::LIMITS); + let left: Vec = (0..17).filter(|i| old.join(format!("{i}@96.png")).exists()).collect(); + assert_eq!(left, (2..17).collect::>(), "15 MiB stay with the new 1 MiB render; the two oldest went"); + // The file cap counts too, under a test's small limits. + cache::evict(&root, &keep, 0, cache::Limits { bytes: u64::MAX, files: 4 }); + let left: Vec = (0..17).filter(|i| old.join(format!("{i}@96.png")).exists()).collect(); + assert_eq!(left, vec![14, 15, 16]); +} + +/// Before a write would fail for room, the render cache is cleared; when +/// that is not enough either, the write fails with `storage_full:`. +#[test] +fn the_render_cache_is_cleared_before_storage_full() { + let roomy = World::new(); + roomy.put("Board minutes.pdf", &fixture::documents().into_iter().find(|(n, _)| *n == "Board minutes.pdf").unwrap().1); + let doc = roomy.open("Board minutes.pdf"); + let mut cached = 0; + for page in [1, 2] { + let r = roomy.ok("page", json!({"doc": doc, "page": page, "dpi": 150})); + cached += std::fs::metadata(roomy.root.join(r["path"].as_str().unwrap())).unwrap().len(); + } + // The same storage, nearly full: 200 bytes left, besides the cache. + let tight = World { _dir: tempfile::tempdir().unwrap(), root: roomy.root.clone(), areas: resolver(&roomy.root, Some(200)) }; + let out = tight.ok("export", json!({"doc": doc, "kind": "text", "out": "minutes.txt"})); + assert_eq!(out["paths"][0], "minutes.txt"); + let text = std::fs::metadata(tight.root.join("minutes.txt")).unwrap().len(); + assert!(text > 200 && text <= 200 + cached, "the export needed the cache's room: {text} bytes"); + for page in [1, 2] { + assert!(!tight.root.join(format!(".cache/pages/{doc}/{page}@150.png")).exists(), "the cache was cleared"); + } + // Nothing left to clear: the write fails as storage_full. + let e = tight.err("export", json!({"doc": doc, "kind": "text", "out": "again.txt"})); + coded(&e, "storage_full"); + assert!(!tight.root.join("again.txt").exists()); + // A render that does not fit what is left fails the same way. + let e = tight.err("page", json!({"doc": doc, "page": 1, "dpi": 150})); + coded(&e, "storage_full"); +} + +/// A cache folder that is a link is refused: the cache never counts, trims +/// or writes files elsewhere in the storage. +#[cfg(unix)] +#[test] +fn the_render_cache_never_follows_a_link() { + let w = World::new().samples(); + std::fs::create_dir_all(w.root.join("accounts/device/library")).unwrap(); + std::fs::copy(w.root.join("Board minutes.pdf"), w.root.join("accounts/device/library/Kept.pdf")).unwrap(); + std::os::unix::fs::symlink(w.root.join("accounts/device/library"), w.root.join(".cache")).unwrap(); + let doc = w.open("Board minutes.pdf"); + coded(&w.err("page", json!({"doc": doc, "page": 1})), "invalid"); + assert!(w.root.join("accounts/device/library/Kept.pdf").is_file(), "nothing in the library was trimmed"); + assert_eq!(std::fs::read_dir(w.root.join("accounts/device/library")).unwrap().count(), 1); + // A document's own folder that is a link, likewise. + std::fs::remove_file(w.root.join(".cache")).unwrap(); + std::fs::create_dir_all(w.root.join(".cache/pages")).unwrap(); + std::os::unix::fs::symlink(w.root.join("accounts/device/library"), w.root.join(format!(".cache/pages/{doc}"))).unwrap(); + coded(&w.err("page", json!({"doc": doc, "page": 1})), "invalid"); + assert_eq!(std::fs::read_dir(w.root.join("accounts/device/library")).unwrap().count(), 1); +} + +// ------------------------------------------------------------ snippets + +#[test] +fn snippets_mark_the_match_within_120_characters() { + let text = "The quarter closed ahead of plan.\nSubscription revenue grew in every region, and revenue from support grew too."; + let (chars, hits) = reading::occurrences(text, "REVENUE"); + assert_eq!(hits.len(), 2); + let first = reading::snippet(&chars, hits[0].0, hits[0].1); + assert!(first.contains("Subscription [[revenue]] grew"), "{first}"); + assert!(first.chars().count() <= reading::SNIPPET); + let (chars, hits) = reading::occurrences(text, "plan. subscription"); + assert_eq!(hits.len(), 1, "a match across a line break, as the engine finds it"); + assert!(reading::snippet(&chars, hits[0].0, hits[0].1).contains("[[plan. Subscription]]")); + let long: String = (0..400).map(|i| if i % 7 == 0 { ' ' } else { 'x' }).collect(); + let text = format!("{long} needle {long}"); + let (chars, hits) = reading::occurrences(&text, "needle"); + let s = reading::snippet(&chars, hits[0].0, hits[0].1); + assert_eq!(s.chars().count(), reading::SNIPPET, "{s}"); + assert!(s.starts_with('…') && s.ends_with('…') && s.contains("[[needle]]"), "{s}"); + let huge: String = "word ".repeat(60); + let (chars, hits) = reading::occurrences(&huge, &huge); + let s = reading::snippet(&chars, hits[0].0, hits[0].1); + assert!(s.chars().count() <= reading::SNIPPET && s.starts_with("[[word") && s.ends_with("…]]"), "{s}"); +} + +#[test] +fn pdf_dates_read_as_iso_8601() { + assert_eq!(review::date(&json!("2026-10-10 14:30")), json!("2026-10-10T14:30"), "the engine's listing: the file's wall clock"); + assert_eq!(review::date(&json!("D:20261010143000+02'00'")), json!("2026-10-10T14:30:00+02:00")); + assert_eq!(review::date(&json!("D:20261010143000Z")), json!("2026-10-10T14:30:00Z")); + assert_eq!(review::date(&json!("D:20261010")), json!("2026-10-10T00:00:00Z")); + assert_eq!(review::date(&json!("yesterday")), json!("yesterday")); + assert_eq!(review::date(&Json::Null), Json::Null); +} + +#[test] +fn every_v2_method_refuses_arguments_it_does_not_take() { + let w = World::new().samples(); + let doc = w.open("Board minutes.pdf"); + for (method, args) in [ + ("open", json!({"path": "Board minutes.pdf", "password": "x"})), + ("close", json!({"doc": doc, "force": true})), + ("state", json!({"doc": doc, "x": 1})), + ("page", json!({"doc": doc, "page": 1, "out": "elsewhere.png"})), + ("find", json!({"doc": doc, "query": "x", "case_sensitive": true})), + ("lines", json!({"doc": doc, "page": 1, "all": true})), + ("fields", json!({"doc": doc, "page": 1})), + ("fill", json!({"doc": doc, "values": {"a": "b"}, "script": "x"})), + ("fill_sign", json!({"doc": doc, "page": 1, "kind": "check", "at": [1, 1], "author": "x"})), + ("pages", json!({"doc": doc, "op": "extract", "pages": [1], "out": "x.pdf", "delete": true})), + ("pages", json!({"doc": doc, "op": "rotate", "pages": [1], "angle": 90, "subset": "odd"})), + ("edit_text", json!({"doc": doc, "page": 1, "line": 1, "text": "x", "font": "times"})), + ("undo", json!({"doc": doc, "steps": 2})), + ("save", json!({"doc": doc, "full": true})), + ("export", json!({"doc": doc, "kind": "images", "out_dir": "x", "quality": 50})), + ] { + let e = w.err(method, args.clone()); + coded(&e, "invalid"); + assert!(e.contains("is not one of them"), "{method} {args}: {e}"); + } + assert_eq!(w.ok("state", json!({"doc": doc}))["edited"], false, "nothing reached the document"); + assert!(!Path::new(&w.root.join("x.pdf")).exists()); +} + +/// What each method costs on a typical page, and a 100-page document's +/// first page, printed: engine work runs on the shell's UI thread (#399), +/// and SERVICE.md asks for well under 100 ms a typical page. Measure in +/// release: +/// +/// cargo test --release --locked -p octosense-pdf-service --lib -- --ignored --nocapture timings +#[test] +#[ignore = "prints timings; run by hand, in release"] +fn timings_on_a_typical_page() { + let w = World::new().samples(); + w.put("form.pdf", &scripted_form()); + let mut report = Vec::new(); + let mut time = |label: &str, method: &str, args: Json| -> Json { + let started = Instant::now(); + let out = w.ok(method, args); + report.push(format!("{label:<34} {:>8.1} ms", started.elapsed().as_secs_f64() * 1000.0)); + out + }; + let doc = time("open (4 pages)", "open", json!({"path": "Quarterly report.pdf"}))["doc"].clone(); + time("page 1 at 96 dpi", "page", json!({"doc": doc, "page": 1})); + time("page 1 at 96 dpi, cached", "page", json!({"doc": doc, "page": 1})); + time("page 2 at 96 dpi", "page", json!({"doc": doc, "page": 2})); + time("page 3 at 150 dpi", "page", json!({"doc": doc, "page": 3, "dpi": 150})); + time("page 4 at 36 dpi (a thumbnail)", "page", json!({"doc": doc, "page": 4, "dpi": 36})); + time("find, first", "find", json!({"doc": doc, "query": "revenue"})); + time("find, again", "find", json!({"doc": doc, "query": "quarter"})); + time("lines", "lines", json!({"doc": doc, "page": 2})); + time("comment add (note)", "comment", json!({"doc": doc, "op": "add", "page": 2, "type": "note", "at": [60, 120], "text": "x"})); + time("comments", "comments", json!({"doc": doc})); + time("fill_sign (date)", "fill_sign", json!({"doc": doc, "page": 4, "kind": "date", "at": [60, 300]})); + time("pages rotate", "pages", json!({"doc": doc, "op": "rotate", "pages": [1], "angle": 90})); + time("page 1 at 96 dpi, after an edit", "page", json!({"doc": doc, "page": 1})); + time("edit_text (a line)", "edit_text", json!({"doc": doc, "page": 2, "line": 1, "text": "Overview"})); + time("undo", "undo", json!({"doc": doc})); + time("redo", "redo", json!({"doc": doc})); + time("state", "state", json!({"doc": doc})); + time("save (incremental)", "save", json!({"doc": doc})); + time("export text", "export", json!({"doc": doc, "kind": "text", "out": "report.txt"})); + time("close", "close", json!({"doc": doc})); + let form = time("open (a form)", "open", json!({"path": "form.pdf"}))["doc"].clone(); + time("fields", "fields", json!({"doc": form})); + time("fill (2 fields)", "fill", json!({"doc": form, "values": {"a": "5", "v": "x"}})); + // A 112-page document: 16 field guides combined. + let guides: Vec<&str> = vec!["Field guide.pdf"; 16]; + w.ok("merge", json!({"paths": guides, "out": "guides.pdf"})); + let big = time("open (112 pages)", "open", json!({"path": "guides.pdf"}))["doc"].clone(); + time("its page 1 at 96 dpi", "page", json!({"doc": big, "page": 1})); + time("find in 112 pages, first", "find", json!({"doc": big, "query": "garden"})); + eprintln!("pdf service timings:\n{}", report.join("\n")); +} diff --git a/apps/pdf/host-service/src/docs.rs b/apps/pdf/host-service/src/docs.rs new file mode 100644 index 000000000..5bf047e9a --- /dev/null +++ b/apps/pdf/host-service/src/docs.rs @@ -0,0 +1,366 @@ +//! Open documents (SERVICE.md "Open documents"): `pdf.open` keeps the +//! engine's document, with its edits and undo history, between calls. +//! +//! **Keys.** A document belongs to the caller app, its storage scope (the +//! area's root, as the file system spells it) and its handle. A call from +//! another app or another scope finds no document under that handle and is +//! answered `unknown_doc:`, exactly as for a handle that never existed. A +//! caller has at most [`MAX_OPEN`] documents open, and the service +//! [`MAX_OPEN_ALL`] across callers (`too_many_open:`). +//! +//! **One engine session per document**, made by [`crate::session`]: rooted +//! at the area, with the engine's JavaScript turned off before the file is +//! opened, and checked off. Closing a document drops its session. +//! +//! **The thread.** Every call reaches this service on the shell's UI +//! thread: App Hub's `services::pump` dispatches an app's `host.request` +//! inline from the card runner's event handler, the agent relay runs its +//! calls from `host_tools::pump` (the UI thread; Android's Mail job may +//! drive that pump without a window, and the pdf engine is desktop only), +//! and components' host calls are pumped from the same place. So the table +//! is a `thread_local`, and the UI thread takes no lock for it (makepad's +//! rule: the UI thread never takes a lock another thread can hold). A call +//! on any other thread finds an empty table of its own: its handles are +//! unknown there, which fails closed and shares nothing. +//! +//! **Release.** A document goes on `pdf.close`, and when its caller's +//! storage scope goes away. A document opened from an app's isolate is +//! bound to that isolate's storage scope, makepad's +//! `splash_storage::storage_for_heap` for the call's +//! `Replier::isolate_key()` (UI-thread state, like this table). It goes +//! away when the app closes: App Hub's card shutdown clears the isolate's +//! host tag at once, and freeing the isolate drops its sandbox root; a +//! changed scope (another account's storage) counts as gone too. App Hub +//! offers host services no "isolate closed" hook, so every call first +//! sweeps the table ([`sweep`]) and releases the documents whose scope is +//! gone. A document opened by a caller with no isolate (a component's +//! host call, a test) cannot be bound, and goes after [`IDLE`] without a +//! call. Known gap: a heap key is an address, so a new isolate of the same +//! app can reuse a dead one's key before any call observed the death; its +//! documents then stay open (reachable only by their handles, which the +//! new isolate does not know) and count toward that caller's cap until the +//! shell restarts. Released documents' renders are removed when the +//! caller next opens a document ([`cache::prune`]). + +use std::cell::RefCell; +use std::collections::HashMap; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use octosense_appstore::makepad_widgets::splash_storage::{self, StorageAccess}; +use octosense_engine_area::Area; +use pdfcraft_automation::{Automation, ToolError}; +use serde_json::{json, Value as Json}; + +use crate::args; +use crate::cache; +use crate::codes::{self, fail, invalid, Code}; +use crate::Ctx; + +/// The most documents one caller (app and storage scope) keeps open. +pub(crate) const MAX_OPEN: usize = 8; +/// The most documents open across callers. +pub(crate) const MAX_OPEN_ALL: usize = 32; +/// How long a document no isolate holds stays open without a call. +pub(crate) const IDLE: Duration = Duration::from_secs(15 * 60); + +/// What keeps a document open besides `pdf.close`. +pub(crate) enum Binding { + /// Opened from an app's isolate: open while that isolate's storage + /// scope is. + Isolate { heap: usize, scope: StorageAccess }, + /// Opened by a caller with no isolate: open until [`IDLE`] passes + /// without a call. + Unbound, +} + +/// A render in the cache that shows this document. +pub(crate) struct Rendered { + /// The document's [`OpenDoc::generation`] it shows. + pub generation: u64, + pub width: u64, + pub height: u64, + pub bytes: u64, +} + +/// One open document. +pub(crate) struct OpenDoc { + pub app: String, + pub owner: PathBuf, + pub handle: String, + pub engine: Automation, + /// The engine's id of the document in its session. + pub id: u64, + /// The document's own file, relative to the area: what an incremental + /// save replaces, the file it was opened (or last saved as) from. + pub file: String, + pub binding: Binding, + pub last_used: Instant, + /// Bumped by every change: a render of an older generation is stale. + pub generation: u64, + /// Each page's displayed size in points, rotation applied. + pub sizes: Vec<[f64; 2]>, + /// Renders in the cache, by (page, dpi). + pub renders: HashMap<(u64, u32), Rendered>, +} + +impl OpenDoc { + /// `extra` with this document's engine id: the arguments of a command + /// on it. `extra` is built by the service from reviewed values only. + pub fn args(&self, extra: Json) -> Json { + let mut out = match extra { + Json::Object(map) => Json::Object(map), + _ => json!({}), + }; + out["doc"] = json!(self.id); + out + } + + /// Run one engine command on this document's session. + pub fn call(&mut self, tool: &str, extra: Json) -> Result { + let args = self.args(extra); + crate::run(&mut self.engine, tool, &args) + } + + /// The engine's summary of the document: pages, `dirty`, `undo`, `redo` + /// (`doc_list`, which takes no arguments: the session holds this + /// document alone). + pub fn summary(&mut self) -> Result { + let list = crate::run(&mut self.engine, "doc_list", &json!({})).map_err(|e| codes::refused(&e))?; + let id = self.id; + list["documents"] + .as_array() + .and_then(|docs| docs.iter().find(|d| d["doc"].as_u64() == Some(id))) + .cloned() + .ok_or_else(|| fail(Code::Damaged, "the engine no longer holds this document")) + } + + /// Read the page sizes again (after a change to the pages). + pub fn refresh(&mut self) -> Result<(), String> { + let info = self.call("doc_info", json!({})).map_err(|e| codes::refused(&e))?; + self.sizes = sizes_of(&info); + Ok(()) + } + + /// The document changed: its renders are stale. + pub fn changed(&mut self) { + self.generation += 1; + } + + /// Page `page`'s displayed size, or why there is no such page. + pub fn size(&self, page: u64) -> Result<[f64; 2], String> { + let n = self.sizes.len(); + usize::try_from(page).ok().and_then(|p| p.checked_sub(1)).and_then(|p| self.sizes.get(p)).copied().ok_or_else(|| { + invalid(format!("page {page} is out of range: the document has {n} page{}", if n == 1 { "" } else { "s" })) + }) + } + + fn alive(&self, now: Instant) -> bool { + match &self.binding { + Binding::Isolate { heap, scope } => splash_storage::storage_for_heap(*heap, &self.app).is_some_and(|now| now.same_scope(scope)), + Binding::Unbound => now.saturating_duration_since(self.last_used) < IDLE, + } + } +} + +/// Each page's displayed size from `doc_info`. +pub(crate) fn sizes_of(info: &Json) -> Vec<[f64; 2]> { + let round = |v: &Json| (v.as_f64().unwrap_or(0.0) * 100.0).round() / 100.0; + info["pages"].as_array().map(|pages| pages.iter().map(|p| [round(&p["width"]), round(&p["height"])]).collect()).unwrap_or_default() +} + +#[derive(Default)] +struct Docs { + open: Vec, +} + +impl Docs { + fn find(&self, app: &str, owner: &Path, handle: &str) -> Option { + self.open.iter().position(|d| d.app == app && d.owner == owner && d.handle == handle) + } +} + +thread_local! { + /// The open documents of this thread: the UI thread's (module doc). + static DOCS: RefCell = RefCell::new(Docs::default()); +} + +/// Whose documents a call reaches: its area's root as the file system +/// spells it (two spellings of one folder are one scope). +pub(crate) fn owner(area: &Area) -> PathBuf { + area.root.canonicalize().unwrap_or_else(|_| area.root.clone()) +} + +/// A new handle: unique in this process, and unlike any of an earlier run +/// (it names the document's render folder, which may still be there). +fn mint() -> String { + static SEED: OnceLock = OnceLock::new(); + static NEXT: AtomicU64 = AtomicU64::new(1); + let seed = *SEED.get_or_init(|| { + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); + (nanos as u32) ^ (nanos >> 32) as u32 ^ std::process::id().rotate_left(16) + }); + format!("{seed:08x}{:x}", NEXT.fetch_add(1, Ordering::Relaxed)) +} + +/// The handle a call names: what `pdf.open` returned. Anything else names +/// no document. +fn handle(args: &Json, m: &str) -> Result { + match args.get("doc") { + None | Some(Json::Null) => Err(invalid(format!("{m} needs `doc`, the handle pdf.open returned"))), + Some(Json::String(h)) if !h.is_empty() && h.len() <= 40 && h.bytes().all(|b| b.is_ascii_digit() || b.is_ascii_lowercase()) => Ok(h.clone()), + Some(_) => Err(unknown()), + } +} + +fn unknown() -> String { + fail(Code::UnknownDoc, "no document of this app is open under that handle: open it again") +} + +/// Release the documents whose caller's scope went away (module doc). +pub(crate) fn sweep() { + sweep_at(Instant::now()); +} + +pub(crate) fn sweep_at(now: Instant) { + DOCS.with(|docs| docs.borrow_mut().open.retain(|doc| doc.alive(now))); +} + +/// How many documents are open on this thread (tests). +#[cfg(test)] +pub(crate) fn open_count() -> usize { + DOCS.with(|docs| docs.borrow().open.len()) +} + +/// Run `f` on the document `args.doc` names, if this caller has it open. +/// A panic in the engine closes the document rather than leave it half +/// changed. +pub(crate) fn with_doc(args: &Json, cx: &Ctx, m: &str, f: impl FnOnce(&mut OpenDoc) -> Result) -> Result { + let handle = handle(args, m)?; + let owner = owner(cx.area); + DOCS.with(|docs| { + let mut docs = docs.borrow_mut(); + let i = docs.find(cx.app, &owner, &handle).ok_or_else(unknown)?; + docs.open[i].last_used = Instant::now(); + match catch_unwind(AssertUnwindSafe(|| f(&mut docs.open[i]))) { + Ok(result) => result, + Err(_) => { + docs.open.remove(i); + Err(fail(Code::Damaged, "the PDF engine failed on this document, so it was closed: open it again")) + } + } + }) +} + +/// What keeps a document this call opens open (module doc). +fn bind(cx: &Ctx) -> Binding { + match splash_storage::storage_for_heap(cx.isolate, cx.app) { + Some(scope) => Binding::Isolate { heap: cx.isolate, scope }, + None => Binding::Unbound, + } +} + +/// `pdf.open {path}`. +pub(crate) fn open(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.open"; + args::only(a, &["path"], M)?; + let path = args::need_str(a, "path", M)?; + crate::checked_input(cx.area, path, "path", M)?; + let owner = owner(cx.area); + DOCS.with(|docs| { + let docs = docs.borrow(); + if docs.open.iter().filter(|d| d.app == cx.app && d.owner == owner).count() >= MAX_OPEN { + return Err(fail(Code::TooManyOpen, format!("{MAX_OPEN} documents are open already: close one first"))); + } + if docs.open.len() >= MAX_OPEN_ALL { + return Err(fail(Code::TooManyOpen, "the PDF engine holds as many documents as it can: close one first")); + } + Ok(()) + })?; + let mut engine = crate::session(cx.area)?; + let opened = crate::run(&mut engine, "doc_open", &json!({ "path": path })).map_err(|e| codes::unreadable(&e))?; + let id = opened["doc"].as_u64().ok_or_else(|| fail(Code::Damaged, "the engine opened no document"))?; + let info = crate::run(&mut engine, "doc_info", &json!({ "doc": id })).map_err(|e| codes::unreadable(&e))?; + let file: PathBuf = Path::new(path).components().collect(); + let file = file.to_string_lossy().into_owned(); + let sizes = sizes_of(&info); + let stem = Path::new(&file).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let title = info["title"].as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned).unwrap_or(stem); + let comments = info["annotations"].as_array().map_or(0, |all| all.iter().filter(|c| c["type"] != "Popup" && c["in_reply_to"].is_null()).count()); + let handle = mint(); + let answer = json!({ + "doc": handle, + "path": file, + "pages": sizes.len(), + "sizes": sizes, + "title": title, + "outline": info["outline"].as_array().cloned().unwrap_or_default(), + "fields": info["fields"].as_array().map_or(0, Vec::len), + "comments": comments, + "can_edit": info["document"]["editable"].as_bool().unwrap_or(false), + }); + let doc = OpenDoc { + app: cx.app.to_string(), + owner: owner.clone(), + handle, + engine, + id, + file, + binding: bind(cx), + last_used: Instant::now(), + generation: 0, + sizes, + renders: HashMap::new(), + }; + let open: Vec = DOCS.with(|docs| { + let mut docs = docs.borrow_mut(); + docs.open.push(doc); + docs.open.iter().filter(|d| d.app == cx.app && d.owner == owner).map(|d| d.handle.clone()).collect() + }); + // Renders of documents no longer open in this storage are stale. + cache::prune(cx.area, &open); + Ok(answer) +} + +/// `pdf.close {doc, discard?}`. +pub(crate) fn close(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.close"; + args::only(a, &["doc", "discard"], M)?; + let discard = args::opt_bool(a, "discard", M)?.unwrap_or(false); + let handle = handle(a, M)?; + let owner = owner(cx.area); + DOCS.with(|docs| { + let mut docs = docs.borrow_mut(); + let i = docs.find(cx.app, &owner, &handle).ok_or_else(unknown)?; + let edited = docs.open[i].summary().map(|s| s["dirty"] == true).unwrap_or(false); + if edited && !discard { + return Err(fail(Code::Unsaved, "the document has changes that are not saved: save it first, or close it with discard: true")); + } + docs.open.remove(i); + Ok(()) + })?; + cache::forget(cx.area, &handle); + Ok(json!({ "closed": true })) +} + +/// `{edited, can_undo, can_redo}` from the engine's summary, whose `undo` +/// and `redo` name the edit they would undo or redo (`null`: none). +pub(crate) fn history(summary: &Json) -> Json { + let some = |v: &Json| !v.is_null() && *v != json!(false); + json!({ "edited": summary["dirty"] == true, "can_undo": some(&summary["undo"]), "can_redo": some(&summary["redo"]) }) +} + +/// `pdf.state {doc}`. +pub(crate) fn state(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.state"; + args::only(a, &["doc"], M)?; + with_doc(a, cx, M, |doc| { + let summary = doc.summary()?; + let mut out = history(&summary); + out["pages"] = json!(summary["pages"].as_u64().unwrap_or(doc.sizes.len() as u64)); + Ok(out) + }) +} diff --git a/apps/pdf/host-service/src/lib.rs b/apps/pdf/host-service/src/lib.rs index b11457b40..05025ad8a 100644 --- a/apps/pdf/host-service/src/lib.rs +++ b/apps/pdf/host-service/src/lib.rs @@ -1,34 +1,51 @@ //! `octosense-pdf-service` — the `pdf` host service (ADR 0013). //! -//! pdfcraft's PDF engine through its own headless automation layer. Every -//! call is a fresh, stateless session whose file access is confined to the -//! call's area, first by this service's own path check and then by the -//! engine's root-confined resolver, which refuses `..`, absolute paths and -//! symlink escapes before any I/O. A reference inside a PDF (a remote -//! go-to, a launch action, an external file specification) is reported, -//! never followed, and a document's own scripts never run: every session -//! turns the engine's JavaScript off before it opens anything, so an XFA -//! form's initialize and calculate scripts (which the engine runs on -//! opening by default, in its sandbox) and field scripts stay inert. +//! pdfcraft's PDF engine through its own headless automation layer, in two +//! shapes: +//! +//! - **Files.** A call that names a `path` is a fresh session that opens +//! the file, acts and closes: `info`, `text`, `render`, `merge`, `split`. +//! The system agent's five `pdf.*` tools (`tools.json`) are these. +//! - **Open documents** (PDF Tools v2, `apps/pdftools/design/SERVICE.md`): +//! `open` keeps the engine's document, its edits and undo history, +//! between calls under a handle that later calls name as `doc`; `close`, +//! `state`, `page`, `find`, `lines`, `comments`, `comment`, `fields`, +//! `fill`, `fill_sign`, `pages`, `edit_text`, `undo`, `redo`, `save` and +//! `export` work on it, and `info` and `text` take `{doc}` too. Who holds +//! an open document, on which thread, and what releases it: [`docs`]. //! //! **Where a call works** (ADR 0013, 2026-10-08): the caller's own folder, -//! the [`Area`] the shell's resolver gives it ([`set_area_resolver`]), or -//! without one the legacy `/pdf`. A write that may not replace (an -//! agent's) only creates new files, within the area's quota: a rendered page -//! goes through [`Area::write`], and what the engine writes itself (a merge, -//! a split) lands in a staging folder inside the area first and is moved -//! into place all or nothing ([`octosense_engine_area::Stage`]). +//! the [`Area`] the shell's resolver gives it ([`set_area_resolver`]): an +//! app's storage for its own requests. Without a resolver a call works in +//! the legacy `/pdf`. Every path is relative to the area and kept +//! inside it, first by this service's own check ([`contained`]) and then by +//! the engine's root-confined resolver, which refuses `..`, absolute paths +//! and link escapes before any I/O. What the engine writes lands in a +//! staging folder inside the area first and is moved into place all or +//! nothing ([`octosense_engine_area::Stage`]), within what is left of the +//! storage. A write never replaces a file, except an app's own foreground +//! call where it always could (`merge`, `split`, `render`), the service's +//! own render cache ([`cache`]), and SERVICE.md's incremental `save` over +//! the document's own file. Before a write would fail for room, the render +//! cache is cleared. +//! +//! **Scripts.** A reference inside a PDF (a remote go-to, a launch action, +//! an external file specification) is reported, never followed, and a +//! document's own scripts never run: every session turns the engine's +//! JavaScript off before it opens anything ([`session`]), so an XFA form's +//! initialize and calculate scripts (which the engine runs on opening by +//! default, in its sandbox), field scripts and document scripts stay +//! inert, through every method. +//! +//! **Engine commands** are called only with arguments this service builds +//! from reviewed values, never with a caller's object; every v2 method +//! refuses an argument it does not take ([`args::only`]). Each command's +//! class in `skill/safety.json` and the review of it are beside the +//! methods that call it ([`reading`], [`review`], [`change`]). //! -//! Methods (all under the `pdf` family; paths relative to the area): -//! - `info {path}` → the document inspected as JSON (pages, metadata, -//! outline, fonts, security, repair notes) -//! - `text {path, pages?}` → `{pages: [{page, text}]}` — 1-based pages, -//! in reading order -//! - `render {path, page, out, max_side?}` → `{out, width, height, -//! bytes}` — one page written as a PNG -//! - `merge {paths, out}` → `{out, bytes}` — the files combined, in order -//! - `split {path, out_dir, every? | before?}` → `{files}` — split every -//! N pages, or before the given 1-based page numbers +//! **Errors** start with a stable code ([`codes`]): `not_found:`, +//! `damaged:`, `protected:`, `storage_full:`, `too_many_open:`, +//! `unknown_doc:`, `unsaved:`, `invalid:`, `too_large:`. //! //! The service serves system apps only until ADR 0013's store capability //! is designed. @@ -36,13 +53,24 @@ /// The system agent's skill for this engine (ADR 0013). pub mod skill; +mod args; +mod cache; +mod change; +mod codes; +mod docs; +mod reading; +mod review; + +use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::{Component, Path, PathBuf}; use octosense_appstore::services::{register_host_service, HostService, Replier, ServiceCall, ServiceHost}; use octosense_engine_area::{Area, Slot, Stage}; -use pdfcraft_automation::{Automation, Content}; +use pdfcraft_automation::{Automation, Content, ToolError}; use serde_json::{json, Value as Json}; +use codes::{fail, invalid, Code}; + /// The longest page edge `render` produces. const MAX_RENDER_SIDE: u64 = 4096; /// The largest PDF the service opens (bytes). @@ -52,7 +80,7 @@ const MAX_MERGE_FILES: usize = 16; /// The most files one `split` writes. const MAX_SPLIT_FILES: u64 = 256; /// The most pages one `text` call extracts. -const MAX_TEXT_PAGES: usize = 512; +const MAX_TEXT_PAGES: usize = args::MAX_PAGES; /// Which apps may call the service: system apps, as Sheets and Photo. fn may_call(app_id: &str) -> bool { @@ -86,19 +114,37 @@ impl HostService for PdfService { } fn call(&mut self, call: ServiceCall, reply: Replier, _host: &mut dyn ServiceHost) { - reply.send(serve(&AREAS, &call)); + // App Hub calls this inline, holding its registry's lock: a panic + // in the engine must not unwind through it (and poison every + // service's registry), so it becomes this call's error. + let isolate = reply.isolate_key(); + let answer = catch_unwind(AssertUnwindSafe(|| serve(&AREAS, &call, isolate))) + .unwrap_or_else(|_| Err(fail(Code::Damaged, "the PDF engine failed unexpectedly"))); + reply.send(answer); } } +/// One call: where it works, who made it, and from which isolate. +pub(crate) struct Ctx<'a> { + pub area: &'a Area, + pub app: &'a str, + /// The requesting isolate's heap key (App Hub's `Replier::isolate_key`; + /// a key no isolate has for an agent's or a component's call): what a + /// document it opens is bound to ([`docs`]). + pub isolate: usize, +} + /// One call, in the area `areas` gives it. -fn serve(areas: &Slot, call: &ServiceCall) -> Result { +fn serve(areas: &Slot, call: &ServiceCall, isolate: usize) -> Result { if !may_call(&call.app_id) { - return Err("The pdf service serves system apps only.".into()); + return Err(invalid("the pdf service serves system apps only")); } - let area = areas.area(call, "pdf").map_err(|e| format!("pdf: {e}"))?; - dispatch_in(call.method(), &call.args, &area) - .map(|answer| area.relative_json(answer)) - .map_err(|error| area.relative_text(&error)) + // Documents whose caller's storage scope went away go first, on every + // call (App Hub has no "isolate closed" hook for services). + docs::sweep(); + let area = areas.area(call, "pdf").map_err(|e| invalid(format!("this call has no folder to work in: {e}")))?; + let cx = Ctx { area: &area, app: &call.app_id, isolate }; + dispatch_in(call.method(), &call.args, &cx).map(|answer| area.relative_json(answer)).map_err(|error| area.relative_text(&error)) } /// A fresh engine session whose every file read and write is root-confined @@ -106,22 +152,39 @@ fn serve(areas: &Slot, call: &ServiceCall) -> Result { /// scripts off (the engine's Preferences ▸ JavaScript switch, on by /// default). fn session(area: &Area) -> Result { - let mut a = Automation::new().with_root(area.root.clone()).map_err(|e| format!("pdf: {e}"))?; - tool_json(&mut a, "js_enabled", json!({"enabled": false}), "pdf")?; + let mut a = Automation::new().with_root(area.root.clone()).map_err(|e| invalid(format!("this call's folder: {e}")))?; + run(&mut a, "js_enabled", &json!({"enabled": false})).map_err(|e| fail(Code::Damaged, format!("the engine's scripting could not be turned off: {e}")))?; if a.session().javascript() { - return Err("pdf: the engine's scripting could not be turned off".into()); + return Err(fail(Code::Damaged, "the engine's scripting could not be turned off")); } Ok(a) } -fn dispatch_in(method: &str, args: &Json, area: &Area) -> Result { +fn dispatch_in(method: &str, args: &Json, cx: &Ctx) -> Result { match method { - "info" => info(args, area), - "text" => text(args, area), - "render" => render(args, area), - "merge" => merge(args, area), - "split" => split(args, area), - other => Err(format!("pdf.{other} is not a method of the pdf service")), + "info" => info(args, cx), + "text" => text(args, cx), + "render" => render(args, cx.area), + "merge" => merge(args, cx.area), + "split" => split(args, cx.area), + "open" => docs::open(args, cx), + "close" => docs::close(args, cx), + "state" => docs::state(args, cx), + "page" => reading::page(args, cx), + "find" => reading::find(args, cx), + "lines" => reading::lines(args, cx), + "comments" => review::comments(args, cx), + "comment" => review::comment(args, cx), + "fields" => review::fields(args, cx), + "fill" => review::fill(args, cx), + "fill_sign" => review::fill_sign(args, cx), + "pages" => change::pages(args, cx), + "edit_text" => change::edit_text(args, cx), + "undo" => change::history(args, cx, false), + "redo" => change::history(args, cx, true), + "save" => change::save(args, cx), + "export" => change::export(args, cx), + other => Err(invalid(format!("pdf.{other} is not a method of the pdf service"))), } } @@ -131,35 +194,32 @@ fn dispatch_in(method: &str, args: &Json, area: &Area) -> Result { fn dispatch(method: &str, args: &Json, host_dir: &Path) -> Result { let area = Area::legacy(host_dir, "pdf"); std::fs::create_dir_all(&area.root).map_err(|e| format!("pdf: {e}"))?; - dispatch_in(method, args, &area) + dispatch_in(method, args, &Ctx { area: &area, app: "os.fixture", isolate: 0 }) } /// A staging path (`name` inside `stage`) as the engine names paths: relative /// to its root, the area's. fn staged(area: &Area, stage: &Stage<'_>, name: &str) -> Result { - let dir = stage.dir().strip_prefix(&area.root).map_err(|_| "pdf: the staging folder is outside the area".to_string())?; + let dir = stage.dir().strip_prefix(&area.root).map_err(|_| invalid("the staging folder is outside the area"))?; Ok(dir.join(name).to_string_lossy().into_owned()) } -fn need_str<'a>(args: &'a Json, key: &str, m: &str) -> Result<&'a str, String> { - args[key].as_str().filter(|s| !s.is_empty()).ok_or_else(|| format!("{m}: `{key}` is required")) -} - /// A path strictly inside the area: relative, normal components only, and /// the resolved deepest existing ancestor must stay under the area even /// through symlinks (the stance the sheet service and the files host tools /// take). The engine's resolver enforces the same bound again at I/O time. fn contained(area: &Area, rel: &str, key: &str, m: &str) -> Result { if rel.is_empty() { - return Err(format!("{m}: `{key}` is required")); + return Err(invalid(format!("{m} needs `{key}`"))); } + let outside = || invalid(format!("{m}: `{key}` stays inside this app's storage")); let rel_path = Path::new(rel); if rel_path.is_absolute() || rel_path.components().any(|c| !matches!(c, Component::Normal(_))) { - return Err(format!("{m}: `{key}` stays inside this call's folder")); + return Err(outside()); } let root = &area.root; let joined = root.join(rel_path); - let check_root = root.canonicalize().map_err(|e| format!("{m}: folder: {e}"))?; + let check_root = root.canonicalize().map_err(|e| invalid(format!("{m}: this call's folder: {e}")))?; let deepest = { let mut p = joined.clone(); while !p.exists() { @@ -170,9 +230,9 @@ fn contained(area: &Area, rel: &str, key: &str, m: &str) -> Result Result Result { let out = contained(area, rel, key, m)?; - area.check(&out, 0).map_err(|e| format!("{m}: {e}"))?; + area.check(&out, 0).map_err(codes::area)?; Ok(out) } /// The containment check plus the size cap, for a file a method will read. fn checked_input(area: &Area, rel: &str, key: &str, m: &str) -> Result<(), String> { let path = contained(area, rel, key, m)?; - let meta = std::fs::metadata(&path).map_err(|e| format!("{m}: {rel}: {e}"))?; + let meta = match std::fs::metadata(&path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(fail(Code::NotFound, format!("{rel} is not in this app's storage"))), + Err(e) => return Err(invalid(format!("{rel}: {e}"))), + }; + if !meta.is_file() { + return Err(invalid(format!("{rel} is not a file"))); + } if meta.len() > MAX_PDF_BYTES { - return Err(format!("{m}: {rel} is larger than the service reads ({} MB)", MAX_PDF_BYTES >> 20)); + return Err(fail(Code::TooLarge, format!("{rel} is larger than the service reads ({} MB)", MAX_PDF_BYTES >> 20))); } Ok(()) } /// Run one engine tool and keep its JSON result. -fn tool_json(a: &mut Automation, tool: &str, args: Json, m: &str) -> Result { - let contents = a.call(tool, &args).map_err(|e| format!("{m}: {e}"))?; - for c in contents { +fn run(a: &mut Automation, tool: &str, args: &Json) -> Result { + for c in a.call(tool, args)? { if let Content::Json(v) = c { return Ok(v); } } - Err(format!("{m}: the engine returned no result")) + Err(ToolError::Failed(format!("{tool}: the engine returned no result"))) } /// Open `path` (already checked) and return the session's document id and /// page count. -fn open(a: &mut Automation, path: &str, m: &str) -> Result<(Json, u64), String> { - let opened = tool_json(a, "doc_open", json!({ "path": path }), m)?; +fn open(a: &mut Automation, path: &str) -> Result<(Json, u64), String> { + let opened = run(a, "doc_open", &json!({ "path": path })).map_err(|e| codes::unreadable(&e))?; let pages = opened["pages"].as_u64().unwrap_or(0); Ok((opened["doc"].clone(), pages)) } -fn info(args: &Json, area: &Area) -> Result { +/// `{path}` or `{doc}`, not both: which one a reading method was given. +fn path_or_doc<'a>(args: &'a Json, m: &str) -> Result, String> { + match (args.get("path").filter(|v| !v.is_null()), args.get("doc").filter(|v| !v.is_null())) { + (Some(_), Some(_)) => Err(invalid(format!("{m} takes `path` or `doc`, not both"))), + (_, Some(_)) => Ok(None), + _ => args::need_str(args, "path", m).map(Some), + } +} + +/// The `pages` a text call names, or all of a document of at most +/// [`MAX_TEXT_PAGES`]. +fn text_pages(args: &Json, total: u64, m: &str) -> Result>, String> { + match args::opt_pages(args, "pages", m)? { + Some(pages) => Ok(Some(pages)), + None if total as usize > MAX_TEXT_PAGES => { + Err(fail(Code::TooLarge, format!("{m}: the document has {total} pages; pass `pages` ({MAX_TEXT_PAGES} per call)"))) + } + None => Ok(None), + } +} + +fn info(args: &Json, cx: &Ctx) -> Result { const M: &str = "pdf.info"; - let path = need_str(args, "path", M)?; - checked_input(area, path, "path", M)?; - let mut a = session(area)?; - let (doc, _) = open(&mut a, path, M)?; - let mut out = tool_json(&mut a, "doc_info", json!({ "doc": doc }), M)?; + let Some(path) = path_or_doc(args, M)? else { + args::only(args, &["doc"], M)?; + return docs::with_doc(args, cx, M, |doc| { + let mut out = doc.call("doc_info", json!({})).map_err(|e| codes::refused(&e))?; + // The engine reads the file from wherever it last saved it (a + // staging folder): the document's file is the service's to name. + out["file"] = json!(doc.file); + out["document"]["path"] = json!(doc.file); + Ok(out) + }); + }; + checked_input(cx.area, path, "path", M)?; + let mut a = session(cx.area)?; + let (doc, _) = open(&mut a, path)?; + let mut out = run(&mut a, "doc_info", &json!({ "doc": doc })).map_err(|e| codes::unreadable(&e))?; out["file"] = json!(path); Ok(out) } -fn text(args: &Json, area: &Area) -> Result { +fn text(args: &Json, cx: &Ctx) -> Result { const M: &str = "pdf.text"; - let path = need_str(args, "path", M)?; - checked_input(area, path, "path", M)?; - let mut a = session(area)?; - let (doc, total) = open(&mut a, path, M)?; - let call_args = match &args["pages"] { - Json::Null => { - if total as usize > MAX_TEXT_PAGES { - return Err(format!("{M}: the document has {total} pages; pass `pages` ({MAX_TEXT_PAGES} per call)")); + let Some(path) = path_or_doc(args, M)? else { + args::only(args, &["doc", "pages"], M)?; + return docs::with_doc(args, cx, M, |doc| { + let mut call = json!({}); + if let Some(pages) = text_pages(args, doc.sizes.len() as u64, M)? { + call["pages"] = json!(pages); } - json!({ "doc": doc }) - } - Json::Array(list) if !list.is_empty() && list.len() <= MAX_TEXT_PAGES => { - json!({ "doc": doc, "pages": list }) - } - Json::Array(_) => return Err(format!("{M}: `pages` is 1..={MAX_TEXT_PAGES} page numbers")), - other => return Err(format!("{M}: `pages` is a list of 1-based page numbers, not {other}")), + doc.call("text_extract", call).map_err(|e| codes::refused(&e)) + }); }; - tool_json(&mut a, "text_extract", call_args, M) + checked_input(cx.area, path, "path", M)?; + let mut a = session(cx.area)?; + let (doc, total) = open(&mut a, path)?; + let mut call = json!({ "doc": doc }); + if let Some(pages) = text_pages(args, total, M)? { + call["pages"] = json!(pages); + } + run(&mut a, "text_extract", &call).map_err(|e| codes::refused(&e)) } fn render(args: &Json, area: &Area) -> Result { const M: &str = "pdf.render"; - let path = need_str(args, "path", M)?; - let out = need_str(args, "out", M)?; - let page = args["page"].as_u64().filter(|p| *p >= 1).ok_or(format!("{M}: `page` is a 1-based page number"))?; - let max_side = args["max_side"].as_u64().unwrap_or(1024).clamp(16, MAX_RENDER_SIDE); + let path = args::need_str(args, "path", M)?; + let out = args::need_str(args, "out", M)?; + let page = args::positive(args, "page", M)?; + let max_side = args::opt_int(args, "max_side", M)?.unwrap_or(1024).clamp(16, MAX_RENDER_SIDE as i64) as u64; checked_input(area, path, "path", M)?; let out_abs = out_path(area, out, "out", M)?; let mut a = session(area)?; - let (doc, _) = open(&mut a, path, M)?; + let (doc, _) = open(&mut a, path)?; // The page's size in points decides the dpi that fits `max_side`. - let inspected = tool_json(&mut a, "doc_info", json!({ "doc": doc }), M)?; + let inspected = run(&mut a, "doc_info", &json!({ "doc": doc })).map_err(|e| codes::unreadable(&e))?; let dims = &inspected["pages"][(page - 1) as usize]; let side = dims["width"].as_f64().unwrap_or(0.0).max(dims["height"].as_f64().unwrap_or(0.0)); if side <= 0.0 { - return Err(format!("{M}: the document has no page {page}")); + return Err(invalid(format!("the document has no page {page}"))); } let dpi = (72.0 * max_side as f64 / side).clamp(1.0, 600.0); - let contents = - a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": dpi })).map_err(|e| format!("{M}: {e}"))?; - let Some(Content::Png { data, width, height }) = - contents.into_iter().find(|c| matches!(c, Content::Png { .. })) - else { - return Err(format!("{M}: the engine returned no image")); + let contents = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": dpi })).map_err(|e| codes::refused(&e))?; + let Some(Content::Png { data, width, height }) = contents.into_iter().find(|c| matches!(c, Content::Png { .. })) else { + return Err(fail(Code::Damaged, format!("page {page} could not be rendered"))); }; - area.write(&out_abs, &data).map_err(|e| format!("{M}: {e}"))?; + cache::make_room(area, (data.len() as u64).saturating_sub(cache::regular_len(&out_abs))); + area.write(&out_abs, &data).map_err(codes::area)?; Ok(json!({ "out": out, "width": width, "height": height, "bytes": data.len() })) } +/// A merge input's page range (`"1-4, 9"`): `None` for every page (none +/// given, `""` or `"all"`). Dashes a person types (`–`, `—`) read as `-`. +fn range(text: &str, m: &str) -> Result, String> { + let t = text.trim(); + if t.is_empty() || t.eq_ignore_ascii_case("all") { + return Ok(None); + } + let t: String = t.chars().map(|c| if matches!(c, '–' | '—' | '‒' | '−') { '-' } else { c }).collect(); + if t.len() > 256 || !t.chars().all(|c| c.is_ascii_digit() || matches!(c, ',' | '-' | ' ')) { + return Err(invalid(format!("{m}: `pages` is a range of page numbers such as \"1-4, 9\""))); + } + Ok(Some(t)) +} + fn merge(args: &Json, area: &Area) -> Result { const M: &str = "pdf.merge"; - let out = need_str(args, "out", M)?; + let out = args::need_str(args, "out", M)?; let out_abs = out_path(area, out, "out", M)?; - let paths = args["paths"].as_array().ok_or(format!("{M}: `paths` is a list of files"))?; - if paths.len() < 2 || paths.len() > MAX_MERGE_FILES { - return Err(format!("{M}: `paths` is 2..={MAX_MERGE_FILES} files")); + let items = args["paths"].as_array().ok_or_else(|| invalid(format!("{M}: `paths` is a list of files")))?; + if items.len() < 2 || items.len() > MAX_MERGE_FILES { + return Err(invalid(format!("{M}: `paths` is 2 to {MAX_MERGE_FILES} files"))); } - for p in paths { - let p = p.as_str().filter(|s| !s.is_empty()).ok_or(format!("{M}: every path is a file name"))?; - checked_input(area, p, "paths", M)?; + let mut paths: Vec<&str> = Vec::new(); + let mut ranges: Vec> = Vec::new(); + for item in items { + let (path, pages) = match item { + Json::String(p) => (p.as_str(), None), + Json::Object(o) => { + args::only(item, &["path", "pages"], M)?; + let p = o.get("path").and_then(Json::as_str).ok_or_else(|| invalid(format!("{M}: every input names its `path`")))?; + let pages = match o.get("pages") { + None | Some(Json::Null) => None, + Some(Json::String(r)) => range(r, M)?, + Some(_) => return Err(invalid(format!("{M}: `pages` is a range such as \"1-4, 9\""))), + }; + (p, pages) + } + _ => return Err(invalid(format!("{M}: every input is a path or {{path, pages}}"))), + }; + if path.is_empty() { + return Err(invalid(format!("{M}: every input names a file"))); + } + checked_input(area, path, "paths", M)?; + paths.push(path); + ranges.push(pages); } // The engine writes the merge itself: into a staging folder, then into // place under the area's rules. - let stage = area.stage().map_err(|e| format!("{M}: {e}"))?; + let stage = area.stage().map_err(codes::area)?; let mut a = session(area)?; - let combined = tool_json(&mut a, "doc_combine", json!({ "paths": paths, "out": staged(area, &stage, "merged.pdf")?, "open": false }), M)?; - stage.commit(&[(stage.path("merged.pdf"), out_abs)]).map_err(|e| format!("{M}: {e}"))?; + let mut call = json!({ "paths": paths, "out": staged(area, &stage, "merged.pdf")?, "open": false }); + if ranges.iter().any(Option::is_some) { + call["pages"] = json!(ranges); + } + let combined = run(&mut a, "doc_combine", &call).map_err(|e| codes::read_or_refused(&e))?; + change::commit(area, &stage, &[(stage.path("merged.pdf"), out_abs)])?; Ok(json!({ "out": out, "bytes": combined["bytes"] })) } fn split(args: &Json, area: &Area) -> Result { const M: &str = "pdf.split"; - let path = need_str(args, "path", M)?; - let out_dir = need_str(args, "out_dir", M)?; + let path = args::need_str(args, "path", M)?; + let out_dir = args::need_str(args, "out_dir", M)?; checked_input(area, path, "path", M)?; let out_dir_abs = contained(area, out_dir, "out_dir", M)?; let every = args["every"].as_u64(); let before = args["before"].as_array(); let mut a = session(area)?; - let (doc, total) = open(&mut a, path, M)?; + let (doc, total) = open(&mut a, path)?; // The engine writes the parts itself: into a staging folder, then all // of them into `out_dir` under the area's rules, or none. - let stage = area.stage().map_err(|e| format!("{M}: {e}"))?; + let stage = area.stage().map_err(codes::area)?; let staging = staged(area, &stage, "")?; let call_args = match (every, before) { (Some(n), None) if n >= 1 => { if total.div_ceil(n) > MAX_SPLIT_FILES { - return Err(format!("{M}: splitting {total} pages every {n} makes too many files (at most {MAX_SPLIT_FILES})")); + return Err(fail(Code::TooLarge, format!("splitting {total} pages every {n} makes too many files (at most {MAX_SPLIT_FILES})"))); } json!({ "doc": doc, "out_dir": staging, "every": n }) } (None, Some(b)) if !b.is_empty() && (b.len() as u64) < MAX_SPLIT_FILES => { json!({ "doc": doc, "out_dir": staging, "before": b }) } - _ => return Err(format!("{M}: pass exactly one of `every` (pages per file, ≥ 1) or `before` (1-based page numbers)")), + _ => return Err(invalid(format!("{M}: pass exactly one of `every` (pages per file, ≥ 1) or `before` (1-based page numbers)"))), }; - let parts = tool_json(&mut a, "doc_split", call_args, M)?; + let parts = run(&mut a, "doc_split", &call_args).map_err(|e| codes::refused(&e))?; let moves: Vec<(PathBuf, PathBuf)> = stage.files().into_iter().map(|rel| (stage.path(&rel), out_dir_abs.join(rel))).collect(); - stage.commit(&moves).map_err(|e| format!("{M}: {e}"))?; + change::commit(area, &stage, &moves)?; // The engine reports where it wrote, in the staging folder; callers // speak area-relative paths, where the parts are now. let files: Vec = parts["files"] @@ -343,14 +476,17 @@ fn split(args: &Json, area: &Area) -> Result { Ok(json!({ "files": files })) } +#[cfg(test)] +mod doc_tests; + #[cfg(test)] mod tests { use super::*; /// The value of form field `name` in `path`, as `session` opens it. fn field(a: &mut Automation, path: &str, name: &str) -> Json { - let (doc, _) = open(a, path, "test").unwrap(); - let fields = tool_json(a, "form_fields", json!({"doc": doc}), "test").unwrap(); + let (doc, _) = open(a, path).unwrap(); + let fields = run(a, "form_fields", &json!({"doc": doc})).unwrap(); let list = if fields.is_array() { fields } else { fields["fields"].clone() }; list.as_array().unwrap().iter().find(|f| f["name"] == name).map(|f| f["value"].clone()).unwrap_or_else(|| panic!("no field {name}: {list}")) } @@ -372,15 +508,16 @@ mod tests { let mut a = session(&area).unwrap(); assert!(!a.session().javascript(), "every service session turns scripts off"); assert_ne!(field(&mut a, "form.pdf", "qty"), json!("2"), "no script ran"); - dispatch_in("info", &json!({"path": "form.pdf"}), &area).unwrap(); - dispatch_in("text", &json!({"path": "form.pdf"}), &area).unwrap(); - dispatch_in("render", &json!({"path": "form.pdf", "page": 1, "out": "form.png", "max_side": 64}), &area).unwrap(); + let cx = Ctx { area: &area, app: "os.fixture", isolate: 0 }; + dispatch_in("info", &json!({"path": "form.pdf"}), &cx).unwrap(); + dispatch_in("text", &json!({"path": "form.pdf"}), &cx).unwrap(); + dispatch_in("render", &json!({"path": "form.pdf", "page": 1, "out": "form.png", "max_side": 64}), &cx).unwrap(); } /// A tiny, valid two-page PDF written by this test: Helvetica text on /// each page, exact stream lengths and xref offsets, no compression — /// so the suite needs no fixtures on disk. - fn tiny_pdf(page1: &str, page2: &str) -> Vec { + pub(crate) fn tiny_pdf(page1: &str, page2: &str) -> Vec { let (s1, s2) = ( format!("BT /F1 12 Tf 20 50 Td ({page1}) Tj ET"), format!("BT /F1 12 Tf 20 50 Td ({page2}) Tj ET"), @@ -434,11 +571,12 @@ mod tests { host_dir: host.to_path_buf(), }; let host = host.display().to_string(); - let answer = serve(&Slot::new(), &call(json!({"path": "a.pdf"}))).unwrap(); + let answer = serve(&Slot::new(), &call(json!({"path": "a.pdf"})), 0).unwrap(); assert_eq!(answer["document"]["pages"], json!(2), "{answer}"); assert!(!answer.to_string().contains(&host), "{answer}"); - let error = serve(&Slot::new(), &call(json!({"path": "missing.pdf"}))).unwrap_err(); + let error = serve(&Slot::new(), &call(json!({"path": "missing.pdf"})), 0).unwrap_err(); assert!(!error.contains(&host), "{error}"); + assert!(error.starts_with("not_found: "), "{error}"); } #[test] @@ -516,28 +654,28 @@ mod tests { assert!(a.root.is_dir(), "created on first use"); std::fs::write(a.root.join("a.pdf"), tiny_pdf("in the area", "p2")).unwrap(); for _ in 0..2 { - serve(&Slot::new(), &service_call("render", json!({"path": "a.pdf", "page": 1, "out": "o.png"}), host, false)).unwrap(); + serve(&Slot::new(), &service_call("render", json!({"path": "a.pdf", "page": 1, "out": "o.png"}), host, false), 0).unwrap(); } assert!(a.root.join("o.png").is_file(), "writes land inside the area, replacing as before"); assert!(!host.join("o.png").exists(), "nothing lands beside the area in the shared host dir"); } /// A call as App Hub hands it to the service. - fn service_call(method: &str, args: Json, host_dir: &Path, may_prompt: bool) -> ServiceCall { + pub(crate) fn service_call(method: &str, args: Json, host_dir: &Path, may_prompt: bool) -> ServiceCall { ServiceCall { app_id: "os.fixture".into(), service: format!("pdf.{method}"), args, from_sheet: false, may_prompt, host_dir: host_dir.to_path_buf() } } /// A resolver shaped like the shell's: every call works in `root`, an /// app's own foreground call may replace a file and an agent's may not, /// within `quota`. - fn resolver(root: &Path, quota: Option) -> Slot { + pub(crate) fn resolver(root: &Path, quota: Option) -> Slot { let slot = Slot::new(); let root = root.to_path_buf(); slot.set(Some(std::sync::Arc::new(move |call: &ServiceCall| Ok(Area::new(&root, quota, call.may_prompt))))); slot } - fn no_staging_left(root: &Path) -> bool { + pub(crate) fn no_staging_left(root: &Path) -> bool { std::fs::read_dir(root).unwrap().flatten().all(|e| !e.file_name().to_string_lossy().starts_with(octosense_engine_area::STAGING_PREFIX)) } @@ -553,33 +691,33 @@ mod tests { std::fs::write(root.join("b.pdf"), tiny_pdf("Second file", "Last page")).unwrap(); let areas = resolver(&root, None); let host = dir.path().join(".host"); - let doc = serve(&areas, &service_call("info", json!({"path": "a.pdf"}), &host, false)).unwrap(); + let doc = serve(&areas, &service_call("info", json!({"path": "a.pdf"}), &host, false), 0).unwrap(); assert_eq!(doc["document"]["pages"], json!(2), "{doc}"); - let merged = serve(&areas, &service_call("merge", json!({"paths": ["a.pdf", "b.pdf"], "out": "out/m.pdf"}), &host, false)).unwrap(); + let merged = serve(&areas, &service_call("merge", json!({"paths": ["a.pdf", "b.pdf"], "out": "out/m.pdf"}), &host, false), 0).unwrap(); assert!(merged["bytes"].as_u64().unwrap() > 0, "{merged}"); assert!(root.join("out/m.pdf").is_file()); - let split = serve(&areas, &service_call("split", json!({"path": "out/m.pdf", "out_dir": "parts", "every": 2}), &host, false)).unwrap(); + let split = serve(&areas, &service_call("split", json!({"path": "out/m.pdf", "out_dir": "parts", "every": 2}), &host, false), 0).unwrap(); let files = split["files"].as_array().unwrap(); assert_eq!(files.len(), 2, "{split}"); for f in files { let rel = f["path"].as_str().unwrap(); assert!(rel.starts_with("parts/") && root.join(rel).is_file(), "{rel}"); } - serve(&areas, &service_call("render", json!({"path": "a.pdf", "page": 1, "out": "p1.png", "max_side": 32}), &host, false)).unwrap(); + serve(&areas, &service_call("render", json!({"path": "a.pdf", "page": 1, "out": "p1.png", "max_side": 32}), &host, false), 0).unwrap(); assert!(root.join("p1.png").is_file() && !host.exists() && !root.join("pdf").exists()); assert!(no_staging_left(&root)); std::fs::write(dir.path().join("beside.pdf"), tiny_pdf("x", "y")).unwrap(); for bad in ["../beside.pdf", "/etc/hosts", "out/../../beside.pdf"] { - assert!(serve(&areas, &service_call("info", json!({"path": bad}), &host, false)).is_err(), "{bad}"); - assert!(serve(&areas, &service_call("merge", json!({"paths": ["a.pdf", "b.pdf"], "out": bad}), &host, true)).is_err(), "{bad}"); - assert!(serve(&areas, &service_call("split", json!({"path": "a.pdf", "out_dir": bad, "every": 1}), &host, true)).is_err(), "{bad}"); + assert!(serve(&areas, &service_call("info", json!({"path": bad}), &host, false), 0).is_err(), "{bad}"); + assert!(serve(&areas, &service_call("merge", json!({"paths": ["a.pdf", "b.pdf"], "out": bad}), &host, true), 0).is_err(), "{bad}"); + assert!(serve(&areas, &service_call("split", json!({"path": "a.pdf", "out_dir": bad, "every": 1}), &host, true), 0).is_err(), "{bad}"); } #[cfg(unix)] { std::os::unix::fs::symlink(dir.path(), root.join("up")).unwrap(); - assert!(serve(&areas, &service_call("info", json!({"path": "up/beside.pdf"}), &host, false)).is_err()); - assert!(serve(&areas, &service_call("render", json!({"path": "a.pdf", "page": 1, "out": "up/o.png"}), &host, true)).is_err()); - assert!(serve(&areas, &service_call("split", json!({"path": "a.pdf", "out_dir": "up", "every": 1}), &host, true)).is_err()); + assert!(serve(&areas, &service_call("info", json!({"path": "up/beside.pdf"}), &host, false), 0).is_err()); + assert!(serve(&areas, &service_call("render", json!({"path": "a.pdf", "page": 1, "out": "up/o.png"}), &host, true), 0).is_err()); + assert!(serve(&areas, &service_call("split", json!({"path": "a.pdf", "out_dir": "up", "every": 1}), &host, true), 0).is_err()); assert!(!dir.path().join("o.png").exists() && !dir.path().join("a-part1.pdf").exists()); } } @@ -592,22 +730,22 @@ mod tests { std::fs::write(dir.path().join("a.pdf"), tiny_pdf("Hello PDF", "Page two")).unwrap(); std::fs::write(dir.path().join("taken.pdf"), b"keep me").unwrap(); let areas = resolver(dir.path(), None); - let refused = serve(&areas, &service_call("merge", json!({"paths": ["a.pdf", "a.pdf"], "out": "taken.pdf"}), dir.path(), false)).unwrap_err(); + let refused = serve(&areas, &service_call("merge", json!({"paths": ["a.pdf", "a.pdf"], "out": "taken.pdf"}), dir.path(), false), 0).unwrap_err(); assert!(refused.contains("`taken.pdf` already exists"), "{refused}"); std::fs::write(dir.path().join("p.png"), b"keep").unwrap(); - assert!(serve(&areas, &service_call("render", json!({"path": "a.pdf", "page": 1, "out": "p.png"}), dir.path(), false)).is_err()); + assert!(serve(&areas, &service_call("render", json!({"path": "a.pdf", "page": 1, "out": "p.png"}), dir.path(), false), 0).is_err()); // One part's name is taken: no part lands. std::fs::create_dir(dir.path().join("parts")).unwrap(); std::fs::write(dir.path().join("parts/a-part2.pdf"), b"theirs").unwrap(); - let refused = serve(&areas, &service_call("split", json!({"path": "a.pdf", "out_dir": "parts", "every": 1}), dir.path(), false)).unwrap_err(); + let refused = serve(&areas, &service_call("split", json!({"path": "a.pdf", "out_dir": "parts", "every": 1}), dir.path(), false), 0).unwrap_err(); assert!(refused.contains("already exists"), "{refused}"); assert!(!dir.path().join("parts/a-part1.pdf").exists(), "all or nothing"); assert_eq!(std::fs::read(dir.path().join("parts/a-part2.pdf")).unwrap(), b"theirs"); assert_eq!(std::fs::read(dir.path().join("taken.pdf")).unwrap(), b"keep me"); assert!(no_staging_left(dir.path())); - serve(&areas, &service_call("merge", json!({"paths": ["a.pdf", "a.pdf"], "out": "taken.pdf"}), dir.path(), true)).unwrap(); + serve(&areas, &service_call("merge", json!({"paths": ["a.pdf", "a.pdf"], "out": "taken.pdf"}), dir.path(), true), 0).unwrap(); assert!(std::fs::read(dir.path().join("taken.pdf")).unwrap().starts_with(b"%PDF")); - serve(&areas, &service_call("split", json!({"path": "a.pdf", "out_dir": "parts", "every": 1}), dir.path(), true)).unwrap(); + serve(&areas, &service_call("split", json!({"path": "a.pdf", "out_dir": "parts", "every": 1}), dir.path(), true), 0).unwrap(); assert!(std::fs::read(dir.path().join("parts/a-part2.pdf")).unwrap().starts_with(b"%PDF")); } @@ -623,8 +761,8 @@ mod tests { ("split", json!({"path": "a.pdf", "out_dir": "parts", "every": 1})), ("render", json!({"path": "a.pdf", "page": 1, "out": "p.png"})), ] { - let refused = serve(&tight, &service_call(method, args, dir.path(), true)).unwrap_err(); - assert!(refused.contains("bytes left"), "{method}: {refused}"); + let refused = serve(&tight, &service_call(method, args, dir.path(), true), 0).unwrap_err(); + assert!(refused.starts_with("storage_full: ") && refused.contains("bytes left"), "{method}: {refused}"); } assert!(!dir.path().join("m.pdf").exists() && !dir.path().join("p.png").exists() && !dir.path().join("parts/a-part1.pdf").exists()); assert!(no_staging_left(dir.path())); diff --git a/apps/pdf/host-service/src/reading.rs b/apps/pdf/host-service/src/reading.rs new file mode 100644 index 000000000..5958ad373 --- /dev/null +++ b/apps/pdf/host-service/src/reading.rs @@ -0,0 +1,255 @@ +//! Reading an open document (SERVICE.md "Reading"): a page rendered at +//! screen resolution into the render cache, find with match rectangles, +//! and the text lines and paragraphs of a page. Every engine command here +//! is `safe`: `page_render`, `text_find`, `text_extract`, `text_lines` and +//! `text_paragraphs` read the open document and write nothing; the one +//! file a call writes is the service's own render ([`cache::store`]). + +use std::collections::BTreeMap; + +use pdfcraft_automation::Content; +use serde_json::{json, Value as Json}; + +use crate::args::{self, xywh}; +use crate::cache; +use crate::codes::{self, fail, invalid, Code}; +use crate::docs::{self, Rendered}; +use crate::Ctx; + +/// `pdf.page`'s resolutions (SERVICE.md: 24 to 300, default 96). +pub(crate) const DPI_RANGE: (u32, u32) = (24, 300); +pub(crate) const DEFAULT_DPI: u32 = 96; +/// The most pixels one render makes (16 megapixels). +pub(crate) const MAX_PIXELS: u64 = 16_000_000; +/// `pdf.find`'s `limit` (SERVICE.md: at most 500, default 200). +pub(crate) const MAX_FIND: u64 = 500; +pub(crate) const DEFAULT_FIND: u64 = 200; +/// How many matches `total` counts at most: the engine's work for one +/// find stays bounded, however common the query. +pub(crate) const FIND_COUNT: u64 = 10_000; +/// The longest query. +const MAX_QUERY: usize = 1_000; +/// A snippet's length in characters, the `[[` and `]]` around the match +/// and any `…` included. +pub(crate) const SNIPPET: usize = 120; + +/// Device pixels that cover `points` at `dpi`, rounded up as the engine's +/// renderer does (`pdfcraft_render::device_pixels`). +pub(crate) fn pixels(points: f64, dpi: u32) -> u64 { + let px = points * f64::from(dpi) / 72.0 - 0.01; + if px.is_finite() { px.ceil().max(1.0) as u64 } else { u64::MAX } +} + +/// The pixel cap for a page of `size` points at `dpi`. +pub(crate) fn check_pixels(size: [f64; 2], dpi: u32, page: u64) -> Result<(u64, u64), String> { + let (w, h) = (pixels(size[0], dpi), pixels(size[1], dpi)); + if w.saturating_mul(h) > MAX_PIXELS { + return Err(fail(Code::TooLarge, format!("page {page} at {dpi} dpi is {w} × {h} pixels, more than {} megapixels: choose a lower dpi", MAX_PIXELS / 1_000_000))); + } + Ok((w, h)) +} + +/// `pdf.page {doc, page, dpi?}`: the page as a PNG in the render cache. +pub(crate) fn page(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.page"; + args::only(a, &["doc", "page", "dpi"], M)?; + let page = args::positive(a, "page", M)?; + let dpi = args::dpi(a, "dpi", M, DEFAULT_DPI, DPI_RANGE)?; + docs::with_doc(a, cx, M, |doc| { + let size = doc.size(page)?; + check_pixels(size, dpi, page)?; + let rel = cache::render_path(&doc.handle, page, dpi); + if let Some(r) = doc.renders.get(&(page, dpi)) { + if r.generation == doc.generation && cache::still_there(cx.area, &rel, r.bytes) { + return Ok(json!({ "path": rel, "width": r.width, "height": r.height, "dpi": dpi })); + } + } + let contents = doc.engine.call("page_render", &doc.args(json!({ "page": page, "dpi": dpi }))).map_err(|e| codes::refused(&e))?; + let Some(Content::Png { data, width, height }) = contents.into_iter().find(|c| matches!(c, Content::Png { .. })) else { + return Err(fail(Code::Damaged, format!("page {page} could not be rendered"))); + }; + cache::store(cx.area, &rel, &data)?; + let (width, height) = (u64::from(width), u64::from(height)); + doc.renders.insert((page, dpi), Rendered { generation: doc.generation, width, height, bytes: data.len() as u64 }); + Ok(json!({ "path": rel, "width": width, "height": height, "dpi": dpi })) + }) +} + +/// `pdf.find {doc, query, limit?}`. +pub(crate) fn find(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.find"; + args::only(a, &["doc", "query", "limit"], M)?; + let query = args::need_str(a, "query", M)?; + if query.chars().count() > MAX_QUERY { + return Err(fail(Code::TooLarge, format!("{M}: `query` is at most {MAX_QUERY} characters"))); + } + if query.trim().is_empty() { + return Err(invalid(format!("{M}: `query` has no words"))); + } + let limit = match args::opt_int(a, "limit", M)? { + None => DEFAULT_FIND, + Some(n) if (1..=MAX_FIND as i64).contains(&n) => n as u64, + Some(_) => return Err(invalid(format!("{M}: `limit` is 1 to {MAX_FIND}"))), + }; + docs::with_doc(a, cx, M, |doc| { + let found = doc.call("text_find", json!({ "query": query, "limit": FIND_COUNT })).map_err(|e| codes::refused(&e))?; + let all = found["matches"].as_array().cloned().unwrap_or_default(); + let shown: Vec<&Json> = all.iter().take(limit as usize).collect(); + // The page text, to cut each match's snippet from (the engine's + // find has just extracted it: these read its cache). + let mut pages: Vec = shown.iter().filter_map(|m| m["page"].as_u64()).collect(); + pages.dedup(); + let mut texts: BTreeMap> = BTreeMap::new(); + let mut flat: BTreeMap> = BTreeMap::new(); + if !pages.is_empty() { + let extracted = doc.call("text_extract", json!({ "pages": pages })).map_err(|e| codes::refused(&e))?; + for p in extracted["pages"].as_array().into_iter().flatten() { + if let (Some(n), Some(text)) = (p["page"].as_u64(), p["text"].as_str()) { + let (chars, hits) = occurrences(text, query); + flat.insert(n, chars); + texts.insert(n, hits); + } + } + } + let mut seen: BTreeMap = BTreeMap::new(); + let matches: Vec = shown + .iter() + .map(|m| { + let page = m["page"].as_u64().unwrap_or(0); + let k = seen.entry(page).or_insert(0); + let hit = texts.get(&page).and_then(|hits| hits.get(*k)).copied(); + *k += 1; + let snippet = match (hit, flat.get(&page)) { + (Some((start, end)), Some(chars)) => snippet(chars, start, end), + // The page text did not show the match where the engine + // found it: the match alone. + _ => marked(&collapse(m["text"].as_str().unwrap_or(query)).chars().collect::>()), + }; + json!({ + "page": page, + "rects": m["rects"].as_array().map(|r| r.iter().map(xywh).collect::>()).unwrap_or_default(), + "snippet": snippet, + }) + }) + .collect(); + Ok(json!({ "total": all.len(), "matches": matches })) + }) +} + +/// `text` with every run of whitespace one space, trimmed. +fn collapse(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +/// The page text with whitespace collapsed, as characters, and where +/// `needle` occurs in it, as the engine's find matches: case-insensitive, +/// whitespace-normalised, left to right without overlaps. The k-th +/// occurrence is the engine's k-th match on the page. +pub(crate) fn occurrences(text: &str, needle: &str) -> (Vec, Vec<(usize, usize)>) { + let chars: Vec = collapse(text).chars().collect(); + // Lower-cased, each lower character mapped back to its character. + let mut lower: Vec<(char, usize)> = Vec::with_capacity(chars.len()); + for (i, c) in chars.iter().enumerate() { + for l in c.to_lowercase() { + lower.push((l, i)); + } + } + let needle: Vec = collapse(&needle.to_lowercase()).chars().collect(); + let mut hits = Vec::new(); + if needle.is_empty() { + return (chars, hits); + } + let mut i = 0; + while i + needle.len() <= lower.len() { + if lower[i..i + needle.len()].iter().map(|c| c.0).eq(needle.iter().copied()) { + hits.push((lower[i].1, lower[i + needle.len() - 1].1 + 1)); + i += needle.len(); + } else { + i += 1; + } + } + (chars, hits) +} + +/// The match `chars[start..end]` marked `[[…]]` in the text around it, at +/// most [`SNIPPET`] characters with `…` where the text was cut. +pub(crate) fn snippet(chars: &[char], start: usize, end: usize) -> String { + let inner = SNIPPET - 4; + if end - start >= inner { + return marked(&chars[start..end]); + } + // Context on both sides, the rest of one side's share to the other. + let room = inner - (end - start); + let mut before = (room / 2).min(start); + let mut after = (room - before).min(chars.len() - end); + before = (room - after).min(start); + // A cut costs an ellipsis. + let cut_before = start - before > 0; + let cut_after = end + after < chars.len(); + let spare = room - before - after; + let need = usize::from(cut_before) + usize::from(cut_after); + // Trim the longer side first, so a side shown whole stays whole. + let mut short = need.saturating_sub(spare); + while short > 0 && (before > 0 || after > 0) { + if after >= before { + after -= 1; + } else { + before -= 1; + } + short -= 1; + } + let cut_before = start - before > 0; + let cut_after = end + after < chars.len(); + let mut out = String::new(); + if cut_before { + out.push('…'); + } + out.extend(chars[start - before..start].iter()); + out.push_str("[["); + out.extend(chars[start..end].iter()); + out.push_str("]]"); + out.extend(chars[end..end + after].iter()); + if cut_after { + out.push('…'); + } + out +} + +/// A match alone, marked, cut with `…` to fit [`SNIPPET`]. +fn marked(text: &[char]) -> String { + let budget = SNIPPET - 4; + let mut out = String::from("[["); + if text.len() > budget { + out.extend(text[..budget - 1].iter()); + out.push('…'); + } else { + out.extend(text.iter()); + } + out.push_str("]]"); + out +} + +/// `pdf.lines {doc, page}`. +pub(crate) fn lines(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.lines"; + args::only(a, &["doc", "page"], M)?; + let page = args::positive(a, "page", M)?; + docs::with_doc(a, cx, M, |doc| { + doc.size(page)?; + let lines = doc.call("text_lines", json!({ "page": page })).map_err(|e| codes::refused(&e))?; + let paragraphs = doc.call("text_paragraphs", json!({ "page": page })).map_err(|e| codes::refused(&e))?; + let lines: Vec = lines["lines"] + .as_array() + .into_iter() + .flatten() + .map(|l| json!({ "n": l["line"], "text": l["text"], "box": xywh(&l["rect"]), "font": l["font"], "size": l["size"] })) + .collect(); + let paragraphs: Vec = paragraphs["paragraphs"] + .as_array() + .into_iter() + .flatten() + .map(|p| json!({ "n": p["paragraph"], "text": p["text"], "box": xywh(&p["rect"]), "lines": p["lines"], "font": p["font"], "size": p["size"] })) + .collect(); + Ok(json!({ "lines": lines, "paragraphs": paragraphs })) + }) +} diff --git a/apps/pdf/host-service/src/review.rs b/apps/pdf/host-service/src/review.rs new file mode 100644 index 000000000..7bc69b298 --- /dev/null +++ b/apps/pdf/host-service/src/review.rs @@ -0,0 +1,412 @@ +//! Comments, form fields and Fill & Sign (SERVICE.md "Comments" and "Fill & +//! Sign"), each an undoable edit of the open document. +//! +//! The safety review of the engine commands behind them: +//! +//! - `comment_add` is `file` in `skill/safety.json`: its `attachment` type +//! reads a file named by `path` into the document. The service builds +//! its arguments itself, from the five types SERVICE.md names +//! (highlight, underline, strikeout, note, textbox) and from `page`, +//! `quads` (made from the caller's `rects`), `at`, `rect`, `color`, +//! `contents` and `author` only; `pdf.comment` refuses any other key +//! (`path`, `file`, `stamp`, `find`…) before any engine work. No +//! parameter that names a file ever reaches the command, so it runs as a +//! plain document edit. +//! - `form_fill` is `code`: with JavaScript on it runs the fields' +//! keystroke, validate, format and calculate scripts. Every session has +//! JavaScript off ([`crate::session`]), so the engine fills through +//! `NoScripts` (the value goes in as given, no script result) and runs +//! no XFA script; Acrobat's built-in AF formats and calculations are +//! evaluated natively by the engine, with no interpreter. The proof is a +//! test document whose calculate script, rejecting validate script, +//! format and keystroke scripts, document script and open action would +//! each leave a mark, and leave none through the service +//! (`doc_tests::a_forms_own_scripts_never_run`). +//! - `comment_list`, `comment_edit`, `comment_delete`, `comment_reply`, +//! `comment_set_status`, `form_fields` and `fill_sign_add` are `safe`. + +use std::collections::BTreeSet; + +use serde_json::{json, Value as Json}; + +use crate::args::{self, xywh}; +use crate::codes::{self, invalid}; +use crate::docs::{self, OpenDoc}; +use crate::Ctx; + +/// The comment types `pdf.comment add` makes (SERVICE.md). +pub(crate) const COMMENT_TYPES: [&str; 5] = ["highlight", "underline", "strikeout", "note", "textbox"]; +/// Review statuses (SERVICE.md). +const STATUSES: [&str; 5] = ["accepted", "rejected", "cancelled", "completed", "none"]; +/// Fill & Sign marks (SERVICE.md). +const MARKS: [&str; 5] = ["text", "date", "initials", "check", "cross"]; +/// A text box made from a single point: its size in points. +const TEXTBOX_FROM_POINT: [f64; 2] = [200.0, 40.0]; +/// The longest comment, reply or typed text. +const MAX_TEXT: usize = 10_000; +/// The longest author name and colour. +const MAX_NAME: usize = 256; +/// The most fields one fill sets. +const MAX_FILL: usize = 1_000; + +/// A comment's id: its name in the file, or, for a comment the file left +/// unnamed, `@-` (its place on the page, as the engine counts +/// it now). +fn comment_id(c: &Json) -> Json { + match c["id"].as_str() { + Some(id) => json!(id), + None => match (c["page"].as_u64(), c["index"].as_u64()) { + (Some(page), Some(index)) => json!(format!("@{page}-{index}")), + _ => Json::Null, + }, + } +} + +/// A comment's date as ISO 8601. The engine lists it as the file's wall +/// clock, `YYYY-MM-DD HH:MM` (pdfcraft-render's `pretty_date`, which drops +/// the seconds and the zone): that becomes `YYYY-MM-DDTHH:MM`, a local +/// time. A raw PDF date (`D:20261010143000+02'00'`) keeps its zone. Any +/// other text stays as the file has it. +pub(crate) fn date(v: &Json) -> Json { + let Some(raw) = v.as_str() else { return Json::Null }; + let b = raw.as_bytes(); + let digit = |i: usize| b.get(i).is_some_and(u8::is_ascii_digit); + if b.len() == 16 && (b[4], b[7], b[10], b[13]) == (b'-', b'-', b' ', b':') && [0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15].into_iter().all(digit) { + return json!(format!("{}T{}", &raw[..10], &raw[11..])); + } + let s = raw.strip_prefix("D:").unwrap_or(raw); + let digits: String = s.chars().take_while(char::is_ascii_digit).collect(); + if digits.len() < 8 { + return json!(raw); + } + let part = |from: usize, to: usize, default: &'static str| digits.get(from..to).unwrap_or(default).to_string(); + let (y, mo, d) = (part(0, 4, "0000"), part(4, 6, "01"), part(6, 8, "01")); + let (h, mi, se) = (part(8, 10, "00"), part(10, 12, "00"), part(12, 14, "00")); + let zone = s[digits.len()..].replace('\'', ""); + let zone = match zone.as_bytes().first() { + Some(b'Z') | None => "Z".to_string(), + Some(b'+' | b'-') if zone.len() >= 3 => { + let (sign, rest) = zone.split_at(1); + let hh = rest.get(0..2).unwrap_or("00"); + let mm = rest.get(2..4).unwrap_or("00"); + format!("{sign}{hh}:{mm}") + } + _ => "Z".to_string(), + }; + json!(format!("{y}-{mo}-{d}T{h}:{mi}:{se}{zone}")) +} + +/// The engine's annotation subtype as SERVICE.md's comment type. +fn kind(subtype: &Json) -> Json { + let t = subtype.as_str().unwrap_or(""); + json!(match t { + "Highlight" => "highlight", + "Underline" => "underline", + "StrikeOut" => "strikeout", + "Squiggly" => "squiggly", + "Text" => "note", + "FreeText" => "textbox", + "Square" => "rectangle", + "Circle" => "oval", + "PolyLine" => "polyline", + "FileAttachment" => "attachment", + other => return json!(other.to_ascii_lowercase()), + }) +} + +/// `pdf.comments {doc}`. +pub(crate) fn comments(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.comments"; + args::only(a, &["doc"], M)?; + docs::with_doc(a, cx, M, |doc| { + let list = doc.call("comment_list", json!({})).map_err(|e| codes::refused(&e))?; + let comments: Vec = list["comments"] + .as_array() + .into_iter() + .flatten() + .map(|c| { + // The engine lists a comment's bounding rectangle (a marked + // passage's lines as one). + let rect = xywh(&c["rect"]); + json!({ + "id": comment_id(c), + "page": c["page"], + "type": kind(&c["type"]), + "author": c["author"], + "text": c["contents"], + "date": date(&c["modified"]), + "color": c["color"], + "status": c["status"].as_str().map(str::to_ascii_lowercase).unwrap_or_else(|| "none".into()), + "rects": if rect.is_null() { json!([]) } else { json!([rect]) }, + "replies": c["replies"].as_array().into_iter().flatten().map(|r| json!({ + "id": comment_id(r), + "author": r["author"], + "text": r["contents"], + "date": date(&r["modified"]), + })).collect::>(), + }) + }) + .collect(); + Ok(json!({ "comments": comments })) + }) +} + +/// Every comment and reply in the document, as the engine lists them. +fn every(doc: &mut OpenDoc) -> Result, String> { + let list = doc.call("comment_list", json!({})).map_err(|e| codes::refused(&e))?; + let mut out = Vec::new(); + for c in list["comments"].as_array().into_iter().flatten() { + out.push(c.clone()); + out.extend(c["replies"].as_array().into_iter().flatten().cloned()); + } + Ok(out) +} + +/// Every comment's and reply's id. +fn ids(doc: &mut OpenDoc) -> Result, String> { + Ok(every(doc)?.iter().filter_map(|c| comment_id(c).as_str().map(str::to_owned)).collect()) +} + +/// The engine's target for comment `id`: its name, or the page and index +/// an `@-` id stands for. +fn target(doc: &mut OpenDoc, id: &str) -> Result { + let place = id.strip_prefix('@').and_then(|r| r.split_once('-')).and_then(|(p, i)| Some((p.parse::().ok()?, i.parse::().ok()?))); + if let Some((page, index)) = place.filter(|(p, i)| *p >= 1 && *i >= 1) { + // A name in the file that happens to read like one is still a name. + if !every(doc)?.iter().any(|c| c["id"].as_str() == Some(id)) { + return Ok(json!({ "page": page, "index": index })); + } + } + Ok(json!({ "id": id })) +} + +/// `pdf.comment {doc, op, …}`. +pub(crate) fn comment(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.comment"; + let op = args::need_str(a, "op", M)?; + match op { + "add" => add(a, cx), + "edit" => edit(a, cx), + "delete" => delete(a, cx), + "reply" => reply(a, cx), + "status" => status(a, cx), + other => Err(invalid(format!("{M}: `op` is add, edit, delete, reply or status, not {other:?}"))), + } +} + +/// `pdf.comment {op: "add", page, type, rects?, at?, color?, text?, author?}`. +fn add(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.comment add"; + // The reviewed parameters, and nothing else (the module doc). + args::only(a, &["doc", "op", "page", "type", "rects", "at", "color", "text", "author"], M)?; + let page = args::positive(a, "page", M)?; + let kind = args::need_str(a, "type", M)?; + if !COMMENT_TYPES.contains(&kind) { + return Err(invalid(format!("{M}: `type` is {}, not {kind:?}", COMMENT_TYPES.join(", ")))); + } + let rects = args::opt_rects(a, "rects", M)?; + let at = args::opt_point(a, "at", M)?; + let color = args::opt_str(a, "color", M, MAX_NAME)?; + let text = args::opt_str(a, "text", M, MAX_TEXT)?; + let author = args::opt_str(a, "author", M, MAX_NAME)?; + let mut call = json!({ "page": page, "type": kind }); + match kind { + "highlight" | "underline" | "strikeout" => { + let rects = rects.ok_or_else(|| invalid(format!("{M}: a {kind} needs `rects`, the text it marks")))?; + // A rectangle on the displayed page as a quad: its top-left, + // top-right, bottom-left and bottom-right corners (the order + // the engine's own `find` makes them in). + let quads: Vec<[f64; 8]> = rects.iter().map(|&[x, y, w, h]| [x, y, x + w, y, x, y + h, x + w, y + h]).collect(); + call["quads"] = json!(quads); + } + "note" => { + let at = at.ok_or_else(|| invalid(format!("{M}: a note needs `at`, where its icon goes")))?; + call["at"] = json!(at); + } + _ => { + let [x, y, w, h] = match (rects.as_deref(), at) { + (Some([one]), _) => *one, + (Some(_), _) => return Err(invalid(format!("{M}: a textbox takes one rectangle"))), + (None, Some([x, y])) => [x, y, TEXTBOX_FROM_POINT[0], TEXTBOX_FROM_POINT[1]], + (None, None) => return Err(invalid(format!("{M}: a textbox needs `rects` (one) or `at`"))), + }; + call["rect"] = json!([x, y, x + w, y + h]); + } + } + if let Some(color) = color { + call["color"] = json!(color); + } + if let Some(text) = text { + call["contents"] = json!(text); + } + if let Some(author) = author { + call["author"] = json!(author); + } + docs::with_doc(a, cx, M, |doc| { + let out = doc.call("comment_add", call).map_err(|e| codes::refused(&e))?; + doc.changed(); + Ok(json!({ "id": comment_id(&out["comment"]) })) + }) +} + +/// `pdf.comment {op: "edit", id, text?, color?}`. +fn edit(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.comment edit"; + args::only(a, &["doc", "op", "id", "text", "color"], M)?; + let id = args::need_str(a, "id", M)?; + let text = args::opt_str(a, "text", M, MAX_TEXT)?; + let color = args::opt_str(a, "color", M, MAX_NAME)?; + if text.is_none() && color.is_none() { + return Err(invalid(format!("{M}: give `text`, `color` or both"))); + } + docs::with_doc(a, cx, M, |doc| { + let mut call = target(doc, id)?; + if let Some(text) = text { + call["contents"] = json!(text); + } + if let Some(color) = color { + call["color"] = json!(color); + } + doc.call("comment_edit", call).map_err(|e| codes::refused(&e))?; + doc.changed(); + Ok(json!({ "id": id })) + }) +} + +/// `pdf.comment {op: "delete", id}`. +fn delete(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.comment delete"; + args::only(a, &["doc", "op", "id"], M)?; + let id = args::need_str(a, "id", M)?; + docs::with_doc(a, cx, M, |doc| { + let call = target(doc, id)?; + doc.call("comment_delete", call).map_err(|e| codes::refused(&e))?; + doc.changed(); + Ok(json!({ "deleted": true })) + }) +} + +/// `pdf.comment {op: "reply", id, text, author?}`: the reply's id. +fn reply(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.comment reply"; + args::only(a, &["doc", "op", "id", "text", "author"], M)?; + let id = args::need_str(a, "id", M)?; + let text = args::opt_str(a, "text", M, MAX_TEXT)?.filter(|t| !t.trim().is_empty()).ok_or_else(|| invalid(format!("{M} needs `text`")))?; + let author = args::opt_str(a, "author", M, MAX_NAME)?; + docs::with_doc(a, cx, M, |doc| { + let before: BTreeSet = ids(doc)?.into_iter().collect(); + let mut call = target(doc, id)?; + call["text"] = json!(text); + if let Some(author) = author { + call["author"] = json!(author); + } + doc.call("comment_reply", call).map_err(|e| codes::refused(&e))?; + doc.changed(); + let reply = ids(doc)?.into_iter().find(|n| !before.contains(n)); + Ok(json!({ "id": reply })) + }) +} + +/// `pdf.comment {op: "status", id, status}`. +fn status(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.comment status"; + args::only(a, &["doc", "op", "id", "status"], M)?; + let id = args::need_str(a, "id", M)?; + let status = args::need_str(a, "status", M)?; + if !STATUSES.contains(&status) { + return Err(invalid(format!("{M}: `status` is {}, not {status:?}", STATUSES.join(", ")))); + } + docs::with_doc(a, cx, M, |doc| { + let mut call = target(doc, id)?; + call["status"] = json!(status); + doc.call("comment_set_status", call).map_err(|e| codes::refused(&e))?; + doc.changed(); + Ok(json!({ "id": id })) + }) +} + +/// `pdf.fields {doc}`. +pub(crate) fn fields(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.fields"; + args::only(a, &["doc"], M)?; + docs::with_doc(a, cx, M, |doc| { + let list = doc.call("form_fields", json!({})).map_err(|e| codes::refused(&e))?; + let fields: Vec = list["fields"] + .as_array() + .into_iter() + .flatten() + .map(|f| { + // Every field's options as {value, label}: a radio group's + // are its buttons' export values. + let options: Vec = f["options"] + .as_array() + .into_iter() + .flatten() + .map(|o| match o.as_str() { + Some(v) => json!({ "value": v, "label": v }), + None => json!({ "value": o["value"], "label": o["label"] }), + }) + .collect(); + json!({ + "name": f["name"], + "type": f["type"], + "value": f["value"], + "required": f["required"] == true, + "page": f["page"], + "rect": xywh(&f["rect"]), + "options": options, + }) + }) + .collect(); + Ok(json!({ "fields": fields })) + }) +} + +/// `pdf.fill {doc, values}`: one undo step, no script runs (module doc). +pub(crate) fn fill(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.fill"; + args::only(a, &["doc", "values"], M)?; + let values = a.get("values").and_then(Json::as_object).filter(|v| !v.is_empty()).ok_or_else(|| invalid(format!("{M} needs `values`, field names to values")))?; + if values.len() > MAX_FILL { + return Err(codes::fail(codes::Code::TooLarge, format!("{M} sets at most {MAX_FILL} fields at once"))); + } + for (name, v) in values { + let fits = match v { + Json::String(s) => s.chars().count() <= MAX_TEXT, + Json::Bool(_) | Json::Number(_) | Json::Null => true, + Json::Array(items) => items.len() <= MAX_FILL && items.iter().all(|i| i.as_str().is_some_and(|s| s.chars().count() <= MAX_TEXT)), + Json::Object(_) => false, + }; + if !fits { + return Err(invalid(format!("{M}: the value for {name:?} is a string, true or false, or a list of strings"))); + } + } + docs::with_doc(a, cx, M, |doc| { + let out = doc.call("form_fill", json!({ "values": values })).map_err(|e| codes::refused(&e))?; + doc.changed(); + Ok(json!({ "filled": out["filled"].as_u64().unwrap_or(values.len() as u64) })) + }) +} + +/// `pdf.fill_sign {doc, page, kind, at, text?}`. +pub(crate) fn fill_sign(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.fill_sign"; + args::only(a, &["doc", "page", "kind", "at", "text"], M)?; + let page = args::positive(a, "page", M)?; + let kind = args::need_str(a, "kind", M)?; + if !MARKS.contains(&kind) { + return Err(invalid(format!("{M}: `kind` is {}, not {kind:?}", MARKS.join(", ")))); + } + let at = args::opt_point(a, "at", M)?.ok_or_else(|| invalid(format!("{M} needs `at`, [x, y] in points")))?; + let text = args::opt_str(a, "text", M, MAX_NAME)?; + let mut call = json!({ "page": page, "type": kind, "at": at }); + if matches!(kind, "text" | "initials") { + let text = text.filter(|t| !t.trim().is_empty()).ok_or_else(|| invalid(format!("{M}: {kind} needs `text`")))?; + call["text"] = json!(text); + } + docs::with_doc(a, cx, M, |doc| { + doc.call("fill_sign_add", call).map_err(|e| codes::refused(&e))?; + doc.changed(); + Ok(json!({ "added": true })) + }) +} diff --git a/crates/engine-area/src/lib.rs b/crates/engine-area/src/lib.rs index 6a6989455..20ba372fb 100644 --- a/crates/engine-area/src/lib.rs +++ b/crates/engine-area/src/lib.rs @@ -110,6 +110,14 @@ impl Area { self.freed.fetch_add(freed, Ordering::Relaxed); } + /// Record `bytes` the call removed from the area outside its own writes + /// (a cache of its own it cleared to make room): the quota may use them + /// again for the rest of the call. Works through a shared reference, so + /// a [`Stage`] borrowing the area sees the room it gives. + pub fn credit(&self, bytes: u64) { + self.freed.fetch_add(bytes, Ordering::Relaxed); + } + /// `path` as the caller names it: relative to the root, never the /// host's own spelling of it. pub fn shown(&self, path: &Path) -> String { @@ -567,6 +575,26 @@ mod tests { assert_eq!(Area::new(dir.path(), None, false).room(), None); } + /// What a call removes outside its own writes (a cache it cleared) is + /// room again, for a plain write and for a stage that borrows the area. + #[test] + fn credited_bytes_are_room_again() { + let dir = tempfile::tempdir().unwrap(); + let area = Area::new(dir.path(), Some(4), false); + assert!(area.write(&dir.path().join("a"), b"123456").unwrap_err().contains("more than the 4 bytes left")); + area.credit(2); + assert_eq!(area.room(), Some(6)); + area.write(&dir.path().join("a"), b"123456").unwrap(); + assert_eq!(area.room(), Some(0)); + let stage = area.stage().unwrap(); + std::fs::write(stage.path("b"), b"12").unwrap(); + assert!(stage.commit(&[(stage.path("b"), dir.path().join("b"))]).is_err(), "no room yet"); + area.credit(2); + stage.commit(&[(stage.path("b"), dir.path().join("b"))]).unwrap(); + assert_eq!(area.room(), Some(0)); + assert_eq!(Area::new(dir.path(), None, false).room(), None, "no quota: nothing to credit"); + } + #[test] fn a_stage_commits_all_or_nothing() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/adr/0013-craft-engines-as-pinned-services.md b/docs/adr/0013-craft-engines-as-pinned-services.md index 035944ae2..664980861 100644 --- a/docs/adr/0013-craft-engines-as-pinned-services.md +++ b/docs/adr/0013-craft-engines-as-pinned-services.md @@ -399,7 +399,7 @@ Each engine's surface: | vector | `vector.info`, `vector.run` | 574 of 679. Built-ins only: `effect.apply` and `appearance.addEffect` (by `effect` or `id`); `perspective.draw` runs only `shape.*` commands, each admitted in turn. | Shapes ≤ 1,000 points; blends ≤ 1,000 steps; repeats, mosaics and grids ≤ 10,000 copies (≤ 10,000 a call); Transform effects ≤ 1,000 copies; the document ≤ 20,000 nodes and 100,000 objects as drawn; a raster `out` ≤ 8192 px a side and 16 MP. | | sound | `sound.info`, `peaks`, `convert`, `trim`, `mix` | No door: soundcraft has no command catalog. | — | | design | `design.info`, `render`, `export` | No door, by decision (#418). | — | -| pdf | `pdf.info`, `text`, `render`, `merge`, `split` | No door: a few fixed operations. | — | +| pdf | `pdf.info`, `text`, `render`, `merge`, `split` | No door: a few fixed operations. Apps' own requests also reach PDF Tools v2's open documents (`apps/pdftools/design/SERVICE.md`): each method runs the commands reviewed for it, with arguments the service builds; `comment_add` without its attachment type or a path; `page_insert_file` reads a PDF in the app's storage; `doc_save`, `page_extract` and the exports write to a staging folder there, moved into place without replacing a file (except a save over the document's own file); `doc_open` and `form_fill` run with JavaScript off. | Apps' own requests: ≤ 8 open documents a caller; renders 24–300 dpi and ≤ 16 MP; find ≤ 500 matches; ≤ 512 pages a call; a render cache of ≤ 16 MiB and 64 files, cleared before a write would fail for room. | | photo | none (Photos' own `photos.info`) | `photo.run`, for apps' own requests only: 692 of 817 ids, each also passing photocraft's own workspace check. | Not yet capped (apps' own requests only). | | sheet | none (the Sheets app's own `sheets.*`) | No door: formula evaluation. | — | diff --git a/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md b/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md index 1880f9d81..f745e5cb0 100644 --- a/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md +++ b/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md @@ -321,7 +321,7 @@ App Hub 的 card-host)。 | vector | `vector.info`、`vector.run` | 679 个中的 574 个。只运行内置项:`effect.apply` 和 `appearance.addEffect`(经 `effect` 或 `id`);`perspective.draw` 只运行 `shape.*` 命令,每条再经过准入。 | 形状 ≤ 1,000 个点;混合 ≤ 1,000 步;重复、马赛克和网格 ≤ 10,000 份(每次调用 ≤ 10,000);变换效果 ≤ 1,000 份;文档按绘制计 ≤ 20,000 个节点、100,000 个对象;栅格 `out` 每边 ≤ 8192 px 且 ≤ 16 MP。 | | sound | `sound.info`、`peaks`、`convert`、`trim`、`mix` | 没有入口:soundcraft 没有命令目录。 | — | | design | `design.info`、`render`、`export` | 按决定不设入口(#418)。 | — | -| pdf | `pdf.info`、`text`、`render`、`merge`、`split` | 没有入口:只有几个固定操作。 | — | +| pdf | `pdf.info`、`text`、`render`、`merge`、`split` | 没有入口:只有几个固定操作。应用自己的请求还能使用 PDF Tools v2 的打开文档(`apps/pdftools/design/SERVICE.md`):每个方法只运行为它审查过的命令,参数由服务自己构造;`comment_add` 不带附件类型或路径;`page_insert_file` 读取应用存储中的 PDF;`doc_save`、`page_extract` 和各种导出写入存储内的暂存文件夹,再移到目标位置,不替换已有文件(保存回文档自己的文件除外);`doc_open` 和 `form_fill` 在 JavaScript 关闭时运行。 | 应用自己的请求:每个调用方 ≤ 8 个打开的文档;渲染 24–300 dpi 且 ≤ 16 MP;查找 ≤ 500 个匹配;每次调用 ≤ 512 页;渲染缓存 ≤ 16 MiB 和 64 个文件,写入会因空间不足失败之前先清空。 | | photo | 无(照片应用自己的 `photos.info`) | `photo.run` 只供应用自己的请求使用:817 个 id 中的 692 个,并且每个还要通过 photocraft 自己的工作区检查。 | 尚未设上限(只供应用自己的请求使用)。 | | sheet | 无(Sheets 应用自己的 `sheets.*`) | 没有入口:公式求值。 | — | From 5a930945b58b3f602182bfcc5e1d5c54611142e8 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:48:46 -0700 Subject: [PATCH 2/4] pdf service v2: cap image exports, mark read-only fields Follow-ups to the accepted contract: - pdf.export images takes at most 64 pages and 256 megapixels in all, each page at most 16 MP, refused as too_large before anything is written: each page is a file against the storage's 256-entry limit, and the render work stays bounded on the UI thread until #399. - pdf.fields returns read_only per field; pdf.fill naming a read-only field answers invalid and fills nothing. Co-Authored-By: Claude Opus 5.5 --- apps/AGENTS.md | 12 ++- apps/pdf/host-service/src/change.rs | 46 ++++++--- apps/pdf/host-service/src/doc_tests.rs | 95 ++++++++++++++++++- apps/pdf/host-service/src/review.rs | 15 ++- .../0013-craft-engines-as-pinned-services.md | 2 +- ...-craft-engines-as-pinned-services.zh-CN.md | 2 +- 6 files changed, 150 insertions(+), 22 deletions(-) diff --git a/apps/AGENTS.md b/apps/AGENTS.md index be66c5421..ade71ecf8 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -105,11 +105,13 @@ app's own agent and `tools.json`, the system toolbox, `glance.publish` and type or a path; `file` commands that write get a staging path inside the storage only; `doc_open` and `form_fill` (`code`) run with JavaScript off, proven by `doc_tests::a_forms_own_scripts_never_run`. Caps: 24–300 dpi and - 16 MP a render, `find` ≤ 500 matches, 512 pages a call, and a render cache - in `.cache/pages/` of 16 MiB and 64 files (an app's storage holds at most - 256 entries for its own writes), oldest first, cleared before a write - would fail with `storage_full:`. Errors start with a stable code - (`src/codes.rs`). + 16 MP a render, `find` ≤ 500 matches, 512 pages a call, an image export + ≤ 64 pages and 256 MP in all (checked before anything is written), and a + render cache in `.cache/pages/` of 16 MiB and 64 files (an app's storage + holds at most 256 entries for its own writes), oldest first, cleared + before a write would fail with `storage_full:`. `pdf.fields` marks + read-only fields, and `pdf.fill` refuses one. Errors start with a stable + code (`src/codes.rs`). - Declare an app's agent in its manifest and `bundle/tools.json`. Keep the input/output schemas consistent with the executor (octos requires an object output schema), and select the actual risk, sharing and confirmation policy. diff --git a/apps/pdf/host-service/src/change.rs b/apps/pdf/host-service/src/change.rs index b64e8555b..d15b486f3 100644 --- a/apps/pdf/host-service/src/change.rs +++ b/apps/pdf/host-service/src/change.rs @@ -19,8 +19,10 @@ //! storage, and never over an existing file except the one write //! SERVICE.md allows, an incremental save over the document's own file. //! `page_extract` gets no `delete`, `separate`, `out_dir` or `open`; -//! `doc_export_images` gets no format but PNG; `doc_save` gets `full` -//! as the service decides. +//! `doc_export_images` gets no format but PNG, at most +//! [`MAX_EXPORT_PAGES`] pages and [`MAX_EXPORT_PIXELS`] in all, each page +//! at most 16 megapixels, all checked before anything is written; +//! `doc_save` gets `full` as the service decides. use std::path::{Path, PathBuf}; @@ -38,6 +40,12 @@ use crate::Ctx; /// default, the engine's. const EXPORT_DPI: (u32, u32) = (24, 300); const EXPORT_DEFAULT_DPI: u32 = 150; +/// The most pages one image export writes: each is a file, and an app's +/// storage holds at most 256 entries for its own writes. +pub(crate) const MAX_EXPORT_PAGES: usize = 64; +/// The most pixels one image export renders in all (256 megapixels): the +/// engine renders on the shell's UI thread until #399. +pub(crate) const MAX_EXPORT_PIXELS: u64 = 256_000_000; /// The longest text an edit types. const MAX_EDIT: usize = 10_000; @@ -244,9 +252,24 @@ pub(crate) fn export(a: &Json, cx: &Ctx) -> Result { let pages = args::opt_pages(a, "pages", M)?; let dir = crate::contained(&area, out_dir, "out_dir", M)?; docs::with_doc(a, cx, M, |doc| { - let pages = all_pages(doc, pages, M)?; + // Every cap before anything is written: the pages, each + // page's pixels, and the pixels of the whole call. + let pages = all_pages(doc, pages, MAX_EXPORT_PAGES, M)?; + let mut pixels: u64 = 0; for &p in &pages { - reading::check_pixels(doc.size(p)?, dpi, p)?; + let (w, h) = reading::check_pixels(doc.size(p)?, dpi, p)?; + pixels = pixels.saturating_add(w * h); + } + if pixels > MAX_EXPORT_PIXELS { + return Err(fail( + Code::TooLarge, + format!( + "{M}: {} pages at {dpi} dpi are {:.1} megapixels, more than the {} megapixels one export renders: export fewer pages, or at a lower dpi", + pages.len(), + pixels as f64 / 1_000_000.0, + MAX_EXPORT_PIXELS / 1_000_000 + ), + )); } let stage = area.stage().map_err(codes::area)?; let staging = crate::staged(&area, &stage, "")?; @@ -264,7 +287,7 @@ pub(crate) fn export(a: &Json, cx: &Ctx) -> Result { let dest = crate::out_path(&area, out, "out", M)?; let name = file_name(out, M)?; docs::with_doc(a, cx, M, |doc| { - let pages = all_pages(doc, pages, M)?; + let pages = all_pages(doc, pages, args::MAX_PAGES, M)?; let stage = area.stage().map_err(codes::area)?; let staged = crate::staged(&area, &stage, &name)?; doc.call("doc_export_text", json!({ "path": staged, "pages": pages })).map_err(|e| codes::refused(&e))?; @@ -276,13 +299,14 @@ pub(crate) fn export(a: &Json, cx: &Ctx) -> Result { } } -/// The pages an export names, or every page of a document of at most -/// [`args::MAX_PAGES`]. -fn all_pages(doc: &OpenDoc, pages: Option>, m: &str) -> Result, String> { +/// The pages an export names, at most `max`, or every page of a document +/// of at most `max`. +fn all_pages(doc: &OpenDoc, pages: Option>, max: usize, m: &str) -> Result, String> { match pages { - Some(pages) => Ok(pages), - None if doc.sizes.len() <= args::MAX_PAGES => Ok((1..=doc.sizes.len() as u64).collect()), - None => Err(fail(Code::TooLarge, format!("{m}: the document has {} pages; pass `pages` (at most {} per call)", doc.sizes.len(), args::MAX_PAGES))), + Some(pages) if pages.len() <= max => Ok(pages), + Some(pages) => Err(fail(Code::TooLarge, format!("{m}: this names {} pages, and one call takes at most {max}", pages.len()))), + None if doc.sizes.len() <= max => Ok((1..=doc.sizes.len() as u64).collect()), + None => Err(fail(Code::TooLarge, format!("{m}: the document has {} pages; pass `pages` (at most {max} per call)", doc.sizes.len()))), } } diff --git a/apps/pdf/host-service/src/doc_tests.rs b/apps/pdf/host-service/src/doc_tests.rs index ac5830b62..78dedea0f 100644 --- a/apps/pdf/host-service/src/doc_tests.rs +++ b/apps/pdf/host-service/src/doc_tests.rs @@ -120,7 +120,8 @@ fn assemble(objs: &[String]) -> Vec { /// `a` has a keystroke and a format script that write `note`; `total` has /// a calculate script (and is in the calculation order); `v` has a /// validate script that refuses every value; a document script writes -/// `note`, and so does the document's open action. +/// `note`, and so does the document's open action. Field `locked` is +/// read-only. fn scripted_form() -> Vec { let widget = |name: &str, value: &str, y: u32, actions: &str| { format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [20 {y} 180 {}] /P 3 0 R /F 4 /DA (/Helv 10 Tf 0 g){actions} >>", y + 20) @@ -129,9 +130,9 @@ fn scripted_form() -> Vec { let page = "BT /Helv 12 Tf 20 280 Td (A scripted form) Tj ET"; let objs = [ // 1 catalog, 2 pages, 3 page - "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 7 0 R] /CO [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >> /OpenAction 9 0 R /Names << /JavaScript 15 0 R >> >>".to_string(), + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 7 0 R 17 0 R] /CO [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >> /OpenAction 9 0 R /Names << /JavaScript 15 0 R >> >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /Helv 8 0 R >> >> /Annots [4 0 R 5 0 R 6 0 R 7 0 R] /Contents 16 0 R >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /Helv 8 0 R >> >> /Annots [4 0 R 5 0 R 6 0 R 7 0 R 17 0 R] /Contents 16 0 R >>".to_string(), // 4–7 the fields widget("a", "1", 240, " /AA << /K 10 0 R /F 11 0 R >>"), widget("total", "untouched", 200, " /AA << /C 12 0 R >>"), @@ -149,10 +150,20 @@ fn scripted_form() -> Vec { // 15 the document scripts' name tree, 16 the page's content "<< /Names [(setup) 14 0 R] >>".to_string(), format!("<< /Length {} >>\nstream\n{page}\nendstream", page.len()), + // 17 a read-only field (/Ff bit 1) + widget("locked", "fixed", 80, " /Ff 1"), ]; assemble(&objs) } +/// A PDF of blank pages, one of each size in points. +fn blank_pages(sizes: &[(f64, f64)]) -> Vec { + let kids: Vec = (0..sizes.len()).map(|i| format!("{} 0 R", i + 3)).collect(); + let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids.join(" "), sizes.len())]; + objs.extend(sizes.iter().map(|(w, h)| format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] >>"))); + assemble(&objs) +} + /// A field's value in `fields` (from pdf.fields). fn value(fields: &Json, name: &str) -> Json { fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).map(|f| f["value"].clone()).unwrap_or_else(|| panic!("no field {name}: {fields}")) @@ -490,6 +501,31 @@ fn fields_fill_and_fill_and_sign() { coded(&w.err("fill_sign", json!({"doc": lease, "page": 1, "kind": "check"})), "invalid"); } +/// `pdf.fields` marks a read-only field, and `pdf.fill` refuses one as +/// `invalid:` before the engine sees the call: nothing of it is filled. +#[test] +fn read_only_fields_are_marked_and_never_filled() { + let w = World::new(); + w.put("form.pdf", &scripted_form()); + let doc = w.open("form.pdf"); + let fields = w.ok("fields", json!({"doc": doc})); + let read_only = |name: &str| fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).unwrap_or_else(|| panic!("no field {name}: {fields}"))["read_only"].clone(); + assert_eq!(read_only("locked"), json!(true), "{fields}"); + for name in ["a", "total", "v", "note"] { + assert_eq!(read_only(name), json!(false), "{name}: {fields}"); + } + let e = w.err("fill", json!({"doc": doc, "values": {"locked": "changed"}})); + coded(&e, "invalid"); + assert!(e.contains("\"locked\" is read-only"), "{e}"); + let e = w.err("fill", json!({"doc": doc, "values": {"a": "9", "locked": "changed"}})); + coded(&e, "invalid"); + assert!(e.contains("nothing was filled"), "{e}"); + let fields = w.ok("fields", json!({"doc": doc})); + assert_eq!((value(&fields, "a"), value(&fields, "locked")), (json!("1"), json!("fixed")), "the whole call was refused"); + assert_eq!(w.ok("state", json!({"doc": doc})), json!({"edited": false, "can_undo": false, "can_redo": false, "pages": 1})); + assert_eq!(w.ok("fill", json!({"doc": doc, "values": {"a": "9"}})), json!({"filled": 1}), "the fields beside it still fill"); +} + /// `doc_open` and `form_fill` are `code`: with JavaScript on, the engine /// runs a form's scripts. Every service session has it off, so a form's /// own scripts never run: the keystroke, format, calculate and refusing @@ -712,6 +748,59 @@ fn exports_and_merges_with_ranges() { assert!(no_staging_left(&w.root)); } +/// An image export takes at most 64 pages and 256 megapixels in all, each +/// page at most 16: at each cap it writes, one over it is `too_large:` +/// before anything is written. +#[test] +fn image_exports_take_64_pages_and_256_megapixels() { + let w = World::new(); + w.put("many.pdf", &blank_pages(&[(200.0, 100.0); 65])); + let many = w.open("many.pdf"); + let images = |doc: &Json, out_dir: &str, dpi: u32, pages: Option>| { + let mut args = json!({"doc": doc, "kind": "images", "out_dir": out_dir, "dpi": dpi}); + if let Some(pages) = pages { + args["pages"] = json!(pages); + } + args + }; + let many = json!(many); + // 65 pages, one over the page cap: every page, or 65 named. + let e = w.err("export", images(&many, "every", 24, None)); + coded(&e, "too_large"); + assert!(e.contains("65 pages") && e.contains("64"), "{e}"); + let e = w.err("export", images(&many, "named", 24, Some((1..=65).collect()))); + coded(&e, "too_large"); + let e = w.err("export", images(&many, "repeated", 24, Some(vec![1; 65]))); + coded(&e, "too_large"); + assert!(!w.root.join("every").exists() && !w.root.join("named").exists() && !w.root.join("repeated").exists() && no_staging_left(&w.root)); + // 64, at the cap. + let out = w.ok("export", images(&many, "sixty-four", 24, Some((1..=64).collect()))); + assert_eq!(out["paths"].as_array().unwrap().len(), 64); + assert_eq!(std::fs::read_dir(w.root.join("sixty-four")).unwrap().count(), 64); + + // At 72 dpi a page of 4000 × 4000 pt is 16,000,000 pixels: 16 of them + // are 256 MP, at the cap; with the 1-pt page after them, one pixel over. + // A page of 4001 × 4000 pt is over the 16 MP of one page. + let mut sizes = vec![(4000.0, 4000.0); 16]; + sizes.push((1.0, 1.0)); + sizes.push((4001.0, 4000.0)); + w.put("posters.pdf", &blank_pages(&sizes)); + let posters = json!(w.open("posters.pdf")); + let e = w.err("export", images(&posters, "over", 72, Some((1..=17).collect()))); + coded(&e, "too_large"); + assert!(e.contains("megapixels") && e.contains("256"), "{e}"); + let e = w.err("export", images(&posters, "one too big", 72, Some(vec![18]))); + coded(&e, "too_large"); + assert!(e.contains("16 megapixels"), "{e}"); + assert!(!w.root.join("over").exists() && !w.root.join("one too big").exists() && no_staging_left(&w.root)); + let out = w.ok("export", images(&posters, "at the cap", 72, Some((1..=16).collect()))); + let paths = out["paths"].as_array().unwrap(); + assert_eq!(paths.len(), 16); + for p in paths { + assert!(std::fs::read(w.root.join(p.as_str().unwrap())).unwrap().starts_with(b"\x89PNG"), "{p}"); + } +} + #[test] fn paths_never_leave_the_storage() { let w = World::new().samples(); diff --git a/apps/pdf/host-service/src/review.rs b/apps/pdf/host-service/src/review.rs index 7bc69b298..eefac336c 100644 --- a/apps/pdf/host-service/src/review.rs +++ b/apps/pdf/host-service/src/review.rs @@ -352,6 +352,7 @@ pub(crate) fn fields(a: &Json, cx: &Ctx) -> Result { "type": f["type"], "value": f["value"], "required": f["required"] == true, + "read_only": f["read_only"] == true, "page": f["page"], "rect": xywh(&f["rect"]), "options": options, @@ -362,7 +363,9 @@ pub(crate) fn fields(a: &Json, cx: &Ctx) -> Result { }) } -/// `pdf.fill {doc, values}`: one undo step, no script runs (module doc). +/// `pdf.fill {doc, values}`: one undo step, no script runs (module doc). A +/// read-only field refuses the whole call before the engine sees it, so +/// nothing of it is filled. pub(crate) fn fill(a: &Json, cx: &Ctx) -> Result { const M: &str = "pdf.fill"; args::only(a, &["doc", "values"], M)?; @@ -382,6 +385,16 @@ pub(crate) fn fill(a: &Json, cx: &Ctx) -> Result { } } docs::with_doc(a, cx, M, |doc| { + let listed = doc.call("form_fields", json!({})).map_err(|e| codes::refused(&e))?; + let locked: Vec = values + .keys() + .filter(|name| listed["fields"].as_array().into_iter().flatten().any(|f| f["name"] == name.as_str() && f["read_only"] == true)) + .map(|name| format!("{name:?}")) + .collect(); + if !locked.is_empty() { + let verb = if locked.len() == 1 { "is" } else { "are" }; + return Err(invalid(format!("{M}: {} {verb} read-only, so nothing was filled", locked.join(", ")))); + } let out = doc.call("form_fill", json!({ "values": values })).map_err(|e| codes::refused(&e))?; doc.changed(); Ok(json!({ "filled": out["filled"].as_u64().unwrap_or(values.len() as u64) })) diff --git a/docs/adr/0013-craft-engines-as-pinned-services.md b/docs/adr/0013-craft-engines-as-pinned-services.md index 664980861..e415c3165 100644 --- a/docs/adr/0013-craft-engines-as-pinned-services.md +++ b/docs/adr/0013-craft-engines-as-pinned-services.md @@ -399,7 +399,7 @@ Each engine's surface: | vector | `vector.info`, `vector.run` | 574 of 679. Built-ins only: `effect.apply` and `appearance.addEffect` (by `effect` or `id`); `perspective.draw` runs only `shape.*` commands, each admitted in turn. | Shapes ≤ 1,000 points; blends ≤ 1,000 steps; repeats, mosaics and grids ≤ 10,000 copies (≤ 10,000 a call); Transform effects ≤ 1,000 copies; the document ≤ 20,000 nodes and 100,000 objects as drawn; a raster `out` ≤ 8192 px a side and 16 MP. | | sound | `sound.info`, `peaks`, `convert`, `trim`, `mix` | No door: soundcraft has no command catalog. | — | | design | `design.info`, `render`, `export` | No door, by decision (#418). | — | -| pdf | `pdf.info`, `text`, `render`, `merge`, `split` | No door: a few fixed operations. Apps' own requests also reach PDF Tools v2's open documents (`apps/pdftools/design/SERVICE.md`): each method runs the commands reviewed for it, with arguments the service builds; `comment_add` without its attachment type or a path; `page_insert_file` reads a PDF in the app's storage; `doc_save`, `page_extract` and the exports write to a staging folder there, moved into place without replacing a file (except a save over the document's own file); `doc_open` and `form_fill` run with JavaScript off. | Apps' own requests: ≤ 8 open documents a caller; renders 24–300 dpi and ≤ 16 MP; find ≤ 500 matches; ≤ 512 pages a call; a render cache of ≤ 16 MiB and 64 files, cleared before a write would fail for room. | +| pdf | `pdf.info`, `text`, `render`, `merge`, `split` | No door: a few fixed operations. Apps' own requests also reach PDF Tools v2's open documents (`apps/pdftools/design/SERVICE.md`): each method runs the commands reviewed for it, with arguments the service builds; `comment_add` without its attachment type or a path; `page_insert_file` reads a PDF in the app's storage; `doc_save`, `page_extract` and the exports write to a staging folder there, moved into place without replacing a file (except a save over the document's own file); `doc_open` and `form_fill` run with JavaScript off. | Apps' own requests: ≤ 8 open documents a caller; renders 24–300 dpi and ≤ 16 MP; find ≤ 500 matches; ≤ 512 pages a call; an image export ≤ 64 pages and 256 MP in all; a render cache of ≤ 16 MiB and 64 files, cleared before a write would fail for room. | | photo | none (Photos' own `photos.info`) | `photo.run`, for apps' own requests only: 692 of 817 ids, each also passing photocraft's own workspace check. | Not yet capped (apps' own requests only). | | sheet | none (the Sheets app's own `sheets.*`) | No door: formula evaluation. | — | diff --git a/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md b/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md index f745e5cb0..c40d2607c 100644 --- a/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md +++ b/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md @@ -321,7 +321,7 @@ App Hub 的 card-host)。 | vector | `vector.info`、`vector.run` | 679 个中的 574 个。只运行内置项:`effect.apply` 和 `appearance.addEffect`(经 `effect` 或 `id`);`perspective.draw` 只运行 `shape.*` 命令,每条再经过准入。 | 形状 ≤ 1,000 个点;混合 ≤ 1,000 步;重复、马赛克和网格 ≤ 10,000 份(每次调用 ≤ 10,000);变换效果 ≤ 1,000 份;文档按绘制计 ≤ 20,000 个节点、100,000 个对象;栅格 `out` 每边 ≤ 8192 px 且 ≤ 16 MP。 | | sound | `sound.info`、`peaks`、`convert`、`trim`、`mix` | 没有入口:soundcraft 没有命令目录。 | — | | design | `design.info`、`render`、`export` | 按决定不设入口(#418)。 | — | -| pdf | `pdf.info`、`text`、`render`、`merge`、`split` | 没有入口:只有几个固定操作。应用自己的请求还能使用 PDF Tools v2 的打开文档(`apps/pdftools/design/SERVICE.md`):每个方法只运行为它审查过的命令,参数由服务自己构造;`comment_add` 不带附件类型或路径;`page_insert_file` 读取应用存储中的 PDF;`doc_save`、`page_extract` 和各种导出写入存储内的暂存文件夹,再移到目标位置,不替换已有文件(保存回文档自己的文件除外);`doc_open` 和 `form_fill` 在 JavaScript 关闭时运行。 | 应用自己的请求:每个调用方 ≤ 8 个打开的文档;渲染 24–300 dpi 且 ≤ 16 MP;查找 ≤ 500 个匹配;每次调用 ≤ 512 页;渲染缓存 ≤ 16 MiB 和 64 个文件,写入会因空间不足失败之前先清空。 | +| pdf | `pdf.info`、`text`、`render`、`merge`、`split` | 没有入口:只有几个固定操作。应用自己的请求还能使用 PDF Tools v2 的打开文档(`apps/pdftools/design/SERVICE.md`):每个方法只运行为它审查过的命令,参数由服务自己构造;`comment_add` 不带附件类型或路径;`page_insert_file` 读取应用存储中的 PDF;`doc_save`、`page_extract` 和各种导出写入存储内的暂存文件夹,再移到目标位置,不替换已有文件(保存回文档自己的文件除外);`doc_open` 和 `form_fill` 在 JavaScript 关闭时运行。 | 应用自己的请求:每个调用方 ≤ 8 个打开的文档;渲染 24–300 dpi 且 ≤ 16 MP;查找 ≤ 500 个匹配;每次调用 ≤ 512 页;一次图像导出 ≤ 64 页、合计 ≤ 256 MP;渲染缓存 ≤ 16 MiB 和 64 个文件,写入会因空间不足失败之前先清空。 | | photo | 无(照片应用自己的 `photos.info`) | `photo.run` 只供应用自己的请求使用:817 个 id 中的 692 个,并且每个还要通过 photocraft 自己的工作区检查。 | 尚未设上限(只供应用自己的请求使用)。 | | sheet | 无(Sheets 应用自己的 `sheets.*`) | 没有入口:公式求值。 | — | From 8f31cde9661249c46807fa4590e4b80ab7e50d32 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:13:49 -0700 Subject: [PATCH 3/4] pdf service v2: release documents when their isolate closes A heap key is an address, so a reopened PDF Tools could get its predecessor's key before any pdf call saw the death: the old documents then survived the sweep and filled the cap of 8. App Hub now tells services when an isolate closes (services::on_isolate_closed, called from cancel_heap; OctoSense-App-Hub#204). The pdf service registers its listener from register(), and every document bound to a closing isolate goes then, with its renders. If a call holds the table, the key waits for the next sweep, which still releases documents whose storage scope changed. - App Hub repinned to #204's head 8c86a4b9: the root Cargo.toml, native-apps.json, and the App Hub source lines in Cargo.lock. - Tests: a real isolate's closing releases its 3 documents and their renders, and a new isolate on the same key opens all 8; work goes on after each save, incremental and save-as (renders, find, text, further saves, reopen). - Docs: the known-gap text is gone from docs.rs and apps/AGENTS.md. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 10 +-- Cargo.toml | 10 +-- apps/AGENTS.md | 13 ++-- apps/pdf/host-service/src/doc_tests.rs | 104 +++++++++++++++++++++++++ apps/pdf/host-service/src/docs.rs | 97 ++++++++++++++++++----- apps/pdf/host-service/src/lib.rs | 9 ++- native-apps.json | 2 +- 7 files changed, 206 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4eccb2d1..50ac0c0d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11395,7 +11395,7 @@ dependencies = [ [[package]] name = "octosense-app-contract" version = "1.11.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8347a489141c0e24cab564883db7c4592935a8b2#8347a489141c0e24cab564883db7c4592935a8b2" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a#8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" dependencies = [ "blake3", "serde", @@ -11406,7 +11406,7 @@ dependencies = [ [[package]] name = "octosense-app-hub" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8347a489141c0e24cab564883db7c4592935a8b2#8347a489141c0e24cab564883db7c4592935a8b2" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a#8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" dependencies = [ "base64 0.22.1", "blake3", @@ -11433,7 +11433,7 @@ dependencies = [ [[package]] name = "octosense-app-hub-app" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8347a489141c0e24cab564883db7c4592935a8b2#8347a489141c0e24cab564883db7c4592935a8b2" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a#8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -11461,7 +11461,7 @@ dependencies = [ [[package]] name = "octosense-app-policy" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8347a489141c0e24cab564883db7c4592935a8b2#8347a489141c0e24cab564883db7c4592935a8b2" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a#8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" dependencies = [ "makepad-widgets", "octosense-app-contract", @@ -11483,7 +11483,7 @@ dependencies = [ [[package]] name = "octosense-appstore" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8347a489141c0e24cab564883db7c4592935a8b2#8347a489141c0e24cab564883db7c4592935a8b2" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a#8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" dependencies = [ "makepad-app-module", "makepad-widgets", diff --git a/Cargo.toml b/Cargo.toml index 16479980e..4e4d43b4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ octosense-reference = { path = "apps/reference" } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19" } octosense-appcard = { path = "apps/appcard/module" } -octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" } makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } @@ -131,12 +131,12 @@ makepad-d3 = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "6cf2d90aa33ff4ae603f49c7557f3ecf986207f0" } # --- App Hub's host service registry the Mail and llm services register # into; the same revision as App Hub's app crate (a native app, above). -octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" } # The app policy (resolution, isolate settings, the Splash adapter) and the # Hub client the Card runner resolves an app's policy with; the shell's # glance tiles run cards under it (crates/shell/src/glance_card.rs). -octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" } -octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" } +octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" } +octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" } # The app contract (ADR 0005), from crates.io by version: App Hub, the shell # and Rinx all depend on `octosense-app-contract = "1"`, so Cargo links ONE # 1.x for the whole build (Cargo.lock pins the exact version OctoSense ships) @@ -439,7 +439,7 @@ ruma = { git = "https://github.com/project-robius/ruma.git", branch = "tsp" } # -i octos-core` still sees one octos source. [patch.crates-io] # Keep the contract identical to the pinned SDK for this release candidate. -octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" } +octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" } nix = { git = "https://github.com/octos-org/octos.git", rev = "18fcd3f16e527d2b601d7ae244f056fb711bb6b8" } # pdfcraft's bootstrap PDF stack: its workspace patches these five diff --git a/apps/AGENTS.md b/apps/AGENTS.md index ade71ecf8..fe0221665 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -94,11 +94,14 @@ app's own agent and `tools.json`, the system toolbox, `glance.publish` and card runner, the relay's and components' calls from `host_tools::pump`), which must take no lock another thread can hold. `pdf.close` releases a document, and so does its app closing: a document is bound to the opening - isolate's storage scope (Makepad's `splash_storage::storage_for_heap` for - `Replier::isolate_key()`, gone when App Hub's card shutdown clears the - isolate's host tag), which every call sweeps first, App Hub having no - "isolate closed" hook for services; a caller with no isolate gets a - 15-minute idle bound. Each method runs reviewed engine commands, its + isolate, and the service's listener on App Hub's + `services::on_isolate_closed` (called from `cancel_heap`, which every host + calls for a closing isolate) releases its documents and their renders + before the isolate's heap key, an address, can be reused. Every call also + sweeps for a changed storage scope (Makepad's + `splash_storage::storage_for_heap` for `Replier::isolate_key()`), and a + caller with no isolate gets a 15-minute idle bound. Each method runs + reviewed engine commands, its review beside it (`src/reading.rs`, `src/review.rs`, `src/change.rs`): the service builds every engine argument itself and each v2 method refuses a key it does not take, so `comment_add` (`file`) never sees its attachment diff --git a/apps/pdf/host-service/src/doc_tests.rs b/apps/pdf/host-service/src/doc_tests.rs index 78dedea0f..5e0625076 100644 --- a/apps/pdf/host-service/src/doc_tests.rs +++ b/apps/pdf/host-service/src/doc_tests.rs @@ -376,6 +376,57 @@ fn a_document_goes_when_its_apps_storage_scope_does() { coded(&w.call_full(APP, "state", json!({"doc": opened["doc"]}), true, heap).unwrap_err(), "unknown_doc"); } +/// An isolate closing (App Hub's `cancel_heap`, which every host calls for +/// one) releases its documents at once, with their renders, before a new +/// isolate can get its heap key: a new isolate with the same key (the same +/// app tag and storage) starts clean, with all 8 documents to open, and +/// the old handles name nothing. +#[test] +fn a_closed_isolates_documents_go_before_its_key_is_reused() { + use makepad_widgets::*; + // The listener, as the shell registers the service. + crate::register(); + let w = World::new().samples(); + let mut cx = Cx::new(Box::new(|_, _| {})); + let mut card = cx.with_vm(|vm| { + makepad_widgets::script_mod(vm); + let value = vm.eval(script! {use mod.widgets.* Splash{}}); + Splash::script_from_value(vm, value) + }); + card.set_host_tag(&mut cx, Some(APP.into())); + card.set_sandbox_dir(&mut cx, Some(w.root.clone())); + card.set_text(&mut cx, "View {}"); + let heap = card.isolate_heap_key(&mut cx).unwrap(); + let from = |method: &str, args: Json| w.call_full(APP, method, args, true, heap); + let renders = |doc: &Json| w.root.join(format!(".cache/pages/{}", doc.as_str().unwrap())); + let old: Vec = (0..3).map(|_| from("open", json!({"path": "Board minutes.pdf"})).unwrap()["doc"].clone()).collect(); + for doc in &old { + from("page", json!({"doc": doc, "page": 1, "dpi": 24})).unwrap(); + assert!(renders(doc).is_dir()); + } + // Another isolate closing changes nothing here. + octosense_appstore::services::cancel_heap(heap.wrapping_add(64)); + assert_eq!(docs::open_count(), 3); + // This one closes: its documents go now, before any pdf call. + octosense_appstore::services::cancel_heap(heap); + assert_eq!(docs::open_count(), 0, "released by the listener"); + assert!(old.iter().all(|doc| !renders(doc).exists()), "their renders went with them"); + // A new isolate gets the same heap key, the same app tag and storage. + card.set_host_tag(&mut cx, Some(APP.into())); + card.set_sandbox_dir(&mut cx, Some(w.root.clone())); + let fresh: Vec = (0..docs::MAX_OPEN).map(|_| from("open", json!({"path": "Field guide.pdf"})).unwrap()["doc"].clone()).collect(); + assert_eq!(fresh.len(), 8, "the whole cap is the new isolate's"); + coded(&from("open", json!({"path": "Field guide.pdf"})).unwrap_err(), "too_many_open"); + for doc in &old { + coded(&from("state", json!({"doc": doc})).unwrap_err(), "unknown_doc"); + } + // Closing while a call holds the table: the next call releases them. + docs::while_busy(|| octosense_appstore::services::cancel_heap(heap)); + assert_eq!(docs::open_count(), 8, "queued, not lost"); + coded(&from("state", json!({"doc": fresh[0]})).unwrap_err(), "unknown_doc"); + assert_eq!(docs::open_count(), 0); +} + #[test] fn a_document_no_isolate_holds_goes_after_it_is_idle() { let w = World::new().samples(); @@ -688,6 +739,59 @@ fn saving_incrementally_and_as_a_new_file() { assert!(no_staging_left(&w.root)); } +/// Every save lands in a staging folder that is then moved into place, so +/// the file the engine saved to is gone: the engine must go on from the +/// bytes it saved. After each save, incremental and full, a page never +/// drawn before renders, find finds, text comes out, further edits save +/// incrementally onto the right file, and reopening shows every saved edit. +#[test] +fn work_goes_on_after_each_save() { + let w = World::new().samples(); + let guide = "Field guide.pdf"; + let note = |doc: &Json, page: u64, text: &str| { + w.ok("comment", json!({"doc": doc, "op": "add", "page": page, "type": "note", "at": [60, 120], "text": text})); + }; + // Page `page` is the bird's: drawn for the first time, found, read. + let still_works = |doc: &Json, page: u64, bird: &str| { + let drawn = w.ok("page", json!({"doc": doc, "page": page, "dpi": 36})); + assert!(std::fs::read(w.root.join(drawn["path"].as_str().unwrap())).unwrap().starts_with(b"\x89PNG"), "{drawn}"); + let found = w.ok("find", json!({"doc": doc, "query": bird})); + assert!(found["matches"].as_array().unwrap().iter().any(|m| m["page"] == page && m["rects"].as_array().is_some_and(|r| !r.is_empty())), "{found}"); + let text = w.ok("text", json!({"doc": doc, "pages": [page]})); + assert!(text["pages"][0]["text"].as_str().unwrap().contains(bird), "{text}"); + }; + let notes = |doc: &Json| -> Vec { + w.ok("comments", json!({"doc": doc}))["comments"].as_array().unwrap().iter().map(|c| c["text"].as_str().unwrap().to_string()).collect() + }; + let doc = json!(w.open(guide)); + note(&doc, 1, "first"); + w.ok("save", json!({"doc": doc})); + let first = std::fs::read(w.root.join(guide)).unwrap(); + still_works(&doc, 2, "Robin"); + note(&doc, 3, "second"); + w.ok("save", json!({"doc": doc})); + let second = std::fs::read(w.root.join(guide)).unwrap(); + assert!(second.len() > first.len() && second.starts_with(&first), "the second save appends to the first"); + still_works(&doc, 3, "Blue tit"); + w.ok("close", json!({"doc": doc})); + let doc = json!(w.open(guide)); + assert_eq!(notes(&doc), ["first", "second"]); + // Save as: the document is the new file from then on. + w.ok("save", json!({"doc": doc, "path": "copies/guide.pdf"})); + let saved_as = std::fs::read(w.root.join("copies/guide.pdf")).unwrap(); + still_works(&doc, 4, "Goldfinch"); + note(&doc, 5, "third"); + w.ok("save", json!({"doc": doc})); + let copy = std::fs::read(w.root.join("copies/guide.pdf")).unwrap(); + assert!(copy.len() > saved_as.len() && copy.starts_with(&saved_as), "the incremental save went onto the new file"); + still_works(&doc, 6, "Wren"); + w.ok("close", json!({"doc": doc})); + assert_eq!(notes(&json!(w.open("copies/guide.pdf"))), ["first", "second", "third"]); + assert_eq!(notes(&json!(w.open(guide))), ["first", "second"], "the original kept its two"); + assert_eq!(std::fs::read(w.root.join(guide)).unwrap(), second); + assert!(no_staging_left(&w.root)); +} + /// A full save goes to a new file and never over an existing one, from the /// foreground too; an incremental save over the document's own file needs /// the foreground; extract and export write new files only. diff --git a/apps/pdf/host-service/src/docs.rs b/apps/pdf/host-service/src/docs.rs index 5bf047e9a..3cb587b5a 100644 --- a/apps/pdf/host-service/src/docs.rs +++ b/apps/pdf/host-service/src/docs.rs @@ -25,24 +25,26 @@ //! //! **Release.** A document goes on `pdf.close`, and when its caller's //! storage scope goes away. A document opened from an app's isolate is -//! bound to that isolate's storage scope, makepad's -//! `splash_storage::storage_for_heap` for the call's -//! `Replier::isolate_key()` (UI-thread state, like this table). It goes -//! away when the app closes: App Hub's card shutdown clears the isolate's -//! host tag at once, and freeing the isolate drops its sandbox root; a -//! changed scope (another account's storage) counts as gone too. App Hub -//! offers host services no "isolate closed" hook, so every call first -//! sweeps the table ([`sweep`]) and releases the documents whose scope is -//! gone. A document opened by a caller with no isolate (a component's -//! host call, a test) cannot be bound, and goes after [`IDLE`] without a -//! call. Known gap: a heap key is an address, so a new isolate of the same -//! app can reuse a dead one's key before any call observed the death; its -//! documents then stay open (reachable only by their handles, which the -//! new isolate does not know) and count toward that caller's cap until the -//! shell restarts. Released documents' renders are removed when the -//! caller next opens a document ([`cache::prune`]). - -use std::cell::RefCell; +//! bound to that isolate (the call's `Replier::isolate_key()`) and to its +//! storage scope (makepad's `splash_storage::storage_for_heap` for that +//! key, UI-thread state like this table). +//! +//! - The isolate closing: App Hub calls the service's listener +//! ([`isolate_closed`], registered with `services::on_isolate_closed` by +//! [`crate::register`]) from `services::cancel_heap`, which every host +//! calls for a closing isolate (App Hub's card shutdown; the shell's +//! glance, script-app and Wasm hosts), on the UI thread, before a new +//! isolate can reuse its heap key: a key is an address. Every document +//! bound to it goes then, with its renders. If a call holds the table at +//! that moment, the key waits for the next [`sweep`]. +//! - The scope changing (another account's storage) or going: every call +//! first sweeps the table ([`sweep`]), which also releases a document +//! opened by a caller with no isolate (a component's host call, a test) +//! after [`IDLE`] without a call. Its renders go with it, and any a +//! released document left behind go when the caller next opens a +//! document ([`cache::prune`]). + +use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::{Path, PathBuf}; @@ -164,6 +166,16 @@ impl OpenDoc { Binding::Unbound => now.saturating_duration_since(self.last_used) < IDLE, } } + + fn bound_to(&self, closed: &[usize]) -> bool { + matches!(&self.binding, Binding::Isolate { heap, .. } if closed.contains(heap)) + } + + /// The document goes: its engine session, and its renders in the + /// service's cache. + fn release(self) { + cache::forget(&Area::new(self.owner.clone(), None, false), &self.handle); + } } /// Each page's displayed size from `doc_info`. @@ -186,6 +198,33 @@ impl Docs { thread_local! { /// The open documents of this thread: the UI thread's (module doc). static DOCS: RefCell = RefCell::new(Docs::default()); + /// Heap keys of isolates that closed while a call held [`DOCS`]: the + /// next [`sweep`] releases their documents. A `Cell`, which a listener + /// can always fill. + static CLOSED: Cell> = const { Cell::new(Vec::new()) }; +} + +/// App Hub's `services::on_isolate_closed` listener ([`crate::register`]): +/// the isolate whose heap key is `heap` closed, so every document bound to +/// it goes now, with its renders, before a new isolate can get the key. +/// If a call holds the table (a listener reached from inside one), the key +/// waits for the next [`sweep`]. +pub(crate) fn isolate_closed(heap: usize) { + let released = DOCS.with(|docs| { + let mut docs = docs.try_borrow_mut().ok()?; + let (gone, kept): (Vec, Vec) = std::mem::take(&mut docs.open).into_iter().partition(|doc| doc.bound_to(&[heap])); + docs.open = kept; + Some(gone) + }); + match released { + // The engine sessions drop and the renders go outside the table. + Some(gone) => gone.into_iter().for_each(OpenDoc::release), + None => CLOSED.with(|closed| { + let mut keys = closed.take(); + keys.push(heap); + closed.set(keys); + }), + } } /// Whose documents a call reaches: its area's root as the file system @@ -220,13 +259,21 @@ fn unknown() -> String { fail(Code::UnknownDoc, "no document of this app is open under that handle: open it again") } -/// Release the documents whose caller's scope went away (module doc). +/// Release the documents of isolates that closed while a call held the +/// table, and those whose caller's scope went away (module doc). pub(crate) fn sweep() { sweep_at(Instant::now()); } pub(crate) fn sweep_at(now: Instant) { - DOCS.with(|docs| docs.borrow_mut().open.retain(|doc| doc.alive(now))); + let closed = CLOSED.with(Cell::take); + let gone: Vec = DOCS.with(|docs| { + let mut docs = docs.borrow_mut(); + let (gone, kept): (Vec, Vec) = std::mem::take(&mut docs.open).into_iter().partition(|doc| doc.bound_to(&closed) || !doc.alive(now)); + docs.open = kept; + gone + }); + gone.into_iter().for_each(OpenDoc::release); } /// How many documents are open on this thread (tests). @@ -235,6 +282,16 @@ pub(crate) fn open_count() -> usize { DOCS.with(|docs| docs.borrow().open.len()) } +/// Run `f` while a call holds the table, as a listener reached from inside +/// one would find it (tests). +#[cfg(test)] +pub(crate) fn while_busy(f: impl FnOnce()) { + DOCS.with(|docs| { + let _held = docs.borrow_mut(); + f(); + }); +} + /// Run `f` on the document `args.doc` names, if this caller has it open. /// A panic in the engine closes the document rather than leave it half /// changed. diff --git a/apps/pdf/host-service/src/lib.rs b/apps/pdf/host-service/src/lib.rs index 05025ad8a..cbc65821f 100644 --- a/apps/pdf/host-service/src/lib.rs +++ b/apps/pdf/host-service/src/lib.rs @@ -89,8 +89,11 @@ fn may_call(app_id: &str) -> bool { pub struct PdfService; -/// Register the `pdf` service with App Hub's host-service registry. +/// Register the `pdf` service with App Hub's host-service registry, and +/// its listener for closing isolates: the open documents an isolate holds +/// go when it closes ([`docs`]). pub fn register() { + octosense_appstore::services::on_isolate_closed(docs::isolate_closed); register_host_service(Box::new(PdfService)); } @@ -139,8 +142,8 @@ fn serve(areas: &Slot, call: &ServiceCall, isolate: usize) -> Result Date: Sat, 10 Oct 2026 16:33:08 -0700 Subject: [PATCH 4/4] Repin App Hub to its merged main (80a64b45) App Hub #204 (services::on_isolate_closed) is merged, and its merge commit is App Hub main. The pin moves from #204's branch head 8c86a4b9 to it: the root Cargo.toml (5 crates), native-apps.json, and the five App Hub source lines of Cargo.lock. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 10 +++++----- Cargo.toml | 10 +++++----- native-apps.json | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50ac0c0d5..34db35901 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11395,7 +11395,7 @@ dependencies = [ [[package]] name = "octosense-app-contract" version = "1.11.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a#8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=80a64b4557aaba71f7c826c5dc69721c21c0f0fc#80a64b4557aaba71f7c826c5dc69721c21c0f0fc" dependencies = [ "blake3", "serde", @@ -11406,7 +11406,7 @@ dependencies = [ [[package]] name = "octosense-app-hub" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a#8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=80a64b4557aaba71f7c826c5dc69721c21c0f0fc#80a64b4557aaba71f7c826c5dc69721c21c0f0fc" dependencies = [ "base64 0.22.1", "blake3", @@ -11433,7 +11433,7 @@ dependencies = [ [[package]] name = "octosense-app-hub-app" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a#8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=80a64b4557aaba71f7c826c5dc69721c21c0f0fc#80a64b4557aaba71f7c826c5dc69721c21c0f0fc" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -11461,7 +11461,7 @@ dependencies = [ [[package]] name = "octosense-app-policy" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a#8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=80a64b4557aaba71f7c826c5dc69721c21c0f0fc#80a64b4557aaba71f7c826c5dc69721c21c0f0fc" dependencies = [ "makepad-widgets", "octosense-app-contract", @@ -11483,7 +11483,7 @@ dependencies = [ [[package]] name = "octosense-appstore" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a#8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=80a64b4557aaba71f7c826c5dc69721c21c0f0fc#80a64b4557aaba71f7c826c5dc69721c21c0f0fc" dependencies = [ "makepad-app-module", "makepad-widgets", diff --git a/Cargo.toml b/Cargo.toml index 4e4d43b4e..75aa209e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ octosense-reference = { path = "apps/reference" } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19" } octosense-appcard = { path = "apps/appcard/module" } -octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "80a64b4557aaba71f7c826c5dc69721c21c0f0fc" } makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } @@ -131,12 +131,12 @@ makepad-d3 = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "6cf2d90aa33ff4ae603f49c7557f3ecf986207f0" } # --- App Hub's host service registry the Mail and llm services register # into; the same revision as App Hub's app crate (a native app, above). -octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "80a64b4557aaba71f7c826c5dc69721c21c0f0fc" } # The app policy (resolution, isolate settings, the Splash adapter) and the # Hub client the Card runner resolves an app's policy with; the shell's # glance tiles run cards under it (crates/shell/src/glance_card.rs). -octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" } -octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" } +octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "80a64b4557aaba71f7c826c5dc69721c21c0f0fc" } +octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "80a64b4557aaba71f7c826c5dc69721c21c0f0fc" } # The app contract (ADR 0005), from crates.io by version: App Hub, the shell # and Rinx all depend on `octosense-app-contract = "1"`, so Cargo links ONE # 1.x for the whole build (Cargo.lock pins the exact version OctoSense ships) @@ -439,7 +439,7 @@ ruma = { git = "https://github.com/project-robius/ruma.git", branch = "tsp" } # -i octos-core` still sees one octos source. [patch.crates-io] # Keep the contract identical to the pinned SDK for this release candidate. -octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a" } +octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "80a64b4557aaba71f7c826c5dc69721c21c0f0fc" } nix = { git = "https://github.com/octos-org/octos.git", rev = "18fcd3f16e527d2b601d7ae244f056fb711bb6b8" } # pdfcraft's bootstrap PDF stack: its workspace patches these five diff --git a/native-apps.json b/native-apps.json index 99b62772b..bc1cb0012 100644 --- a/native-apps.json +++ b/native-apps.json @@ -114,7 +114,7 @@ "id": "apphub", "feature": "app-hub", "crate": "octosense-app-hub-app", - "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "8c86a4b9a26b3eed3eb5e39bcb9e10f4a044194a", "local": null }, + "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "80a64b4557aaba71f7c826c5dc69721c21c0f0fc", "local": null }, "module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"], "bin": null, "default_features": true,