From d47adeff89653e1240dbf02019f0eaf9e8742625 Mon Sep 17 00:00:00 2001 From: Perchenko Anton <101743951+PerchAnton@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:22:58 +0200 Subject: [PATCH] M4: bind property drafts with edit-session and replay UI scenarios (#4594) Binds Properties dialog drafts to the vendored edit-session crate (byte-identical to PerchAnton/edit-session@033f7c3a, ledger in vendor/README) and adds replayable UI scenarios. Includes a merge of main and a test covering the clean Properties inspection-tab live refresh. --- Cargo.lock | 5 + Cargo.toml | 2 + LICENSES.toml | 14 +- NOTICE | 2 + ROADMAP.md | 7 + crates/ui-egui/Cargo.toml | 1 + .../ui-egui/examples/properties_scenarios.rs | 312 ++++++++++++++++++ crates/ui-egui/src/app.rs | 3 +- crates/ui-egui/src/dialogs.rs | 66 +--- crates/ui-egui/src/i18n/tests.rs | 6 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/properties_session.rs | 259 +++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/properties_session.rs | 110 ++++++ docs/document-properties-sessions.md | 100 ++++++ docs/roadmap.md | 7 +- parity/acrobat-features.toml | 8 +- .../cancel-and-escape.json | 38 +++ .../document-properties/close-document.json | 26 ++ .../document-properties/commit-save-undo.json | 62 ++++ .../custom-validation.json | 46 +++ .../document-properties/inspection-tabs.json | 58 ++++ .../document-properties/newer-edit.json | 30 ++ .../no-op-preserves-redo.json | 30 ++ .../reload-same-document.json | 34 ++ .../document-properties/switch-document.json | 30 ++ .../document-properties/undo-aba.json | 22 ++ vendor/README.md | 16 + vendor/edit-session/Cargo.toml | 18 + vendor/edit-session/LICENSE-APACHE | 202 ++++++++++++ vendor/edit-session/LICENSE-MIT | 21 ++ vendor/edit-session/README.md | 4 + vendor/edit-session/rustfmt.toml | 3 + vendor/edit-session/src/lib.rs | 168 ++++++++++ vendor/edit-session/tests/lifecycle.rs | 180 ++++++++++ 35 files changed, 1832 insertions(+), 62 deletions(-) create mode 100644 crates/ui-egui/examples/properties_scenarios.rs create mode 100644 crates/ui-egui/src/properties_session.rs create mode 100644 crates/ui-egui/tests/it/properties_session.rs create mode 100644 docs/document-properties-sessions.md create mode 100644 tests/interactions/document-properties/cancel-and-escape.json create mode 100644 tests/interactions/document-properties/close-document.json create mode 100644 tests/interactions/document-properties/commit-save-undo.json create mode 100644 tests/interactions/document-properties/custom-validation.json create mode 100644 tests/interactions/document-properties/inspection-tabs.json create mode 100644 tests/interactions/document-properties/newer-edit.json create mode 100644 tests/interactions/document-properties/no-op-preserves-redo.json create mode 100644 tests/interactions/document-properties/reload-same-document.json create mode 100644 tests/interactions/document-properties/switch-document.json create mode 100644 tests/interactions/document-properties/undo-aba.json create mode 100644 vendor/edit-session/Cargo.toml create mode 100644 vendor/edit-session/LICENSE-APACHE create mode 100644 vendor/edit-session/LICENSE-MIT create mode 100644 vendor/edit-session/README.md create mode 100644 vendor/edit-session/rustfmt.toml create mode 100644 vendor/edit-session/src/lib.rs create mode 100644 vendor/edit-session/tests/lifecycle.rs diff --git a/Cargo.lock b/Cargo.lock index 0fded78442..7a8da0062a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1725,6 +1725,10 @@ dependencies = [ "zeroize", ] +[[package]] +name = "edit-session" +version = "0.1.0-alpha.2" + [[package]] name = "eframe" version = "0.37.0" @@ -4774,6 +4778,7 @@ dependencies = [ "craft-quit", "craft-ui", "craft-update", + "edit-session", "eframe", "egui", "egui_extras", diff --git a/Cargo.toml b/Cargo.toml index a967af58c2..30367fa35c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] +# Small, revision-bound UI drafts; the existing engine remains the owner of PDF edits/history. +edit-session = { path = "vendor/edit-session", version = "=0.1.0-alpha.2" } # Shared frontend components; engines remain independent of egui. craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. diff --git a/LICENSES.toml b/LICENSES.toml index ac54d9f702..e52a55751e 100644 --- a/LICENSES.toml +++ b/LICENSES.toml @@ -16,7 +16,19 @@ sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352" [[document]] source = "NOTICE" destination = "NOTICE" -sha256 = "d9ba162ad2bc95d22ab9885065e253bb823bd6795068828e7b39858344b660eb" +sha256 = "a7620386409bc691c7e223917db186ec447362f655fd81a1196d9853dcac1330" + +[[document]] +source = "vendor/edit-session/LICENSE-MIT" +licences = ["MIT"] +destination = "vendor/edit-session/LICENSE-MIT" +sha256 = "665a942f9317a358930b470ad522a22a58ff6c346db0395463ec84dd3e19d775" + +[[document]] +source = "vendor/edit-session/LICENSE-APACHE" +licences = ["Apache-2.0"] +destination = "vendor/edit-session/LICENSE-APACHE" +sha256 = "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" [[document]] source = "assets/app-icon/LICENSE-document.txt" diff --git a/NOTICE b/NOTICE index eed2d654b0..0906eb0f9a 100644 --- a/NOTICE +++ b/NOTICE @@ -9,6 +9,8 @@ docs/brand/LICENSE-brand.txt. Third-party material +- edit-session 0.1.0-alpha.2, Copyright (c) 2026 PerchAnton. See vendor/edit-session/LICENSE-MIT and vendor/edit-session/LICENSE-APACHE. + This product bundles third-party material under its own licenses, listed below. The per-file list (author, source, license) is in ATTRIBUTION.md. diff --git a/ROADMAP.md b/ROADMAP.md index b81f8fa519..cf33d5cbf7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -636,6 +636,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-11-10 (M4, #4594):** Reconcile current main and keep the original draft-safety entry in this progress log instead of duplicating it in the shared roadmap header. Preserve inactive password cleanup and the Properties guard; behavior and estimates unchanged. +- **2026-11-09 (M4, #4594):** Reconcile upstream changes in the license ledger, roadmap and UI test registry; retain the vendored draft guard, local interaction replay and upstream split-view tests. Behavior and estimates unchanged; maintainer review remains pending. + - **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged. - **2026-10-12 (M10, `ocr.ocr-web`):** OCR in the browser, partly. The web zip carries the text recognition models in `models/` beside `index.html`; the first time Recognize Text is needed the web build downloads them (about 12 MB, with a spinner, an error and a retry). Pages are rendered on the page and read in Web Workers (up to two at once, so the page keeps responding), built with WebAssembly SIMD: 8.5 s a page instead of 20.5 s in wasmtime, still about 17× native. Where workers can't start, the page reads the pages itself. No automated browser test yet. Estimates unchanged. - **2026-10-12 (M10, `ocr.ocr-web`):** Web OCR shipped: reading scanned pages in Web Workers was checked by hand in a browser (4-page synthetic scan). Still no automated browser test, and a page reads about 17× slower than native. @@ -866,6 +869,10 @@ Newest first: the date, what moved, and the new overall estimate. - M13: common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters. - **2026-10-10 (UI docking):** Tools and Inspector can be regrouped, split beside the PDF, or floated inside the app window. Existing toggles reopen panels at their saved locations; UI commands arrange/reset panels, and preferences retain their layout. Read/full-screen modes preserve the focused document view. No milestone percentage change. +- **2026-10-25 (M4, #4594 review):** Documented the session bridge and replay adapter; the stale notice now participates in translation-gap scanning, with a regression test. The original proposal depended on a shared release exception. The revised proposal vendors the Rust source and keeps the Python replay local. Maintainer review is pending. + +- **2026-10-25 (M4):** Document Properties drafts now reject changed document identities/revisions, including reload and Undo/Redo, through pinned edit-session; real UI JSONL scenarios replay with interaction-check. Broader dialog invalidation remains separate (#3917); estimates unchanged. + ## Revision history **2026-10-27 · minor:** M10 #5005: vector printing preserves annotation optional-content membership. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b4e7022220..77132de42f 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,6 +10,7 @@ rust-version = "1.95" workspace = true [dependencies] +edit-session = { workspace = true } craft-ui = { workspace = true } # The menus: native on macOS, an egui bar elsewhere, from one definition (menus.rs). craft-menu = { workspace = true } diff --git a/crates/ui-egui/examples/properties_scenarios.rs b/crates/ui-egui/examples/properties_scenarios.rs new file mode 100644 index 0000000000..41fd3dfa74 --- /dev/null +++ b/crates/ui-egui/examples/properties_scenarios.rs @@ -0,0 +1,312 @@ +//! JSONL adapter for interaction-check, driving the real Document Properties dialog. +//! +//! Each process owns two synthetic PDFs. Widget edits/clicks use egui_kittest; external +//! document edits, reloads and save/reopen use the real engine. No network or shipped API. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::{BufRead, Read, Write}; +use std::path::PathBuf; +use std::sync::Arc; + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use pdfcraft_engine::{DocId, Edit, Session}; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp}; +use serde::Deserialize; +use serde_json::{Value, json}; + +const LIMIT: u64 = 1024 * 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + protocol: u64, + id: u64, + event: Event, +} + +#[derive(Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum Event { + Open, + Type { field: String, value: String }, + Click { label: String }, + Escape, + ExternalEdit { key: String, value: String }, + ExternalEditUndo { key: String, value: String }, + Switch { document: usize }, + Reload { title: String }, + CloseDocument, + Undo, + Redo, + SaveReopen, + Inspect, +} + +/// Build and save a synthetic two-page PDF with known standard and custom metadata. +fn fixture(title: &str) -> Result>, String> { + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 2).map_err(|e| e.to_string())?; + let id = session.open("fixture.pdf", None, bytes, None).map_err(|e| e.to_string())?; + session + .apply( + id, + Edit::Batch { + label: "Fixture metadata".into(), + edits: vec![ + Edit::SetInfo { key: "Title".into(), value: title.into() }, + Edit::SetInfo { key: "Author".into(), value: "Original author".into() }, + Edit::SetInfo { key: "Untouched".into(), value: "Keep me".into() }, + ], + }, + ) + .map_err(|e| e.to_string())?; + session.save_full_bytes(id).map_err(|e| e.to_string()) +} + +/// Expose actual engine metadata, edit generation and history labels for subset assertions. +fn document_state(doc: &pdfcraft_engine::Document) -> Value { + let custom: serde_json::Map = doc.custom_info().into_iter().map(|(k, v)| (k, json!(v))).collect(); + let custom_count = custom.len(); + json!({ + "title": doc.info_value("Title"), "author": doc.info_value("Author"), + "subject": doc.info_value("Subject"), "keywords": doc.info_value("Keywords"), + "custom": custom, "custom_count": custom_count, "display_title": doc.initial_view().display_title, + "dirty": doc.dirty, "undo": doc.can_undo(), "redo": doc.can_redo(), + "generation": doc.edit_generation(), "pages": doc.info.pages.len(), + }) +} + +struct Adapter { + h: Harness<'static, PdfCraftApp>, + originals: Vec<(DocId, Arc>)>, + reopened: Option, + artifacts: Option, +} + +impl Adapter { + /// Open two independent fixtures and create a deterministic 60 Hz headless UI harness. + fn new(artifacts: Option) -> Result { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en")?; + let mut originals = Vec::new(); + for (name, title) in [("A.pdf", "Original"), ("B.pdf", "Second document")] { + let bytes = fixture(title)?; + app.open_bytes(name, None, bytes.as_ref().clone()).map_err(|e| e.to_string())?; + let id = app.active_ids().map(|(_, id)| id).ok_or("fixture did not open")?; + originals.push((id, bytes)); + } + app.active = Some(0); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| app); + h.run_steps(4); + if let Some(path) = &artifacts { + std::fs::create_dir_all(path).map_err(|e| e.to_string())?; + } + Ok(Self { h, originals, reopened: None, artifacts }) + } + + /// Resolve the current document, returning a protocol error when no tab is active. + fn active(&self) -> Result { + self.h.state().active_ids().map(|(_, id)| id).ok_or_else(|| "no active document".into()) + } + + /// Report whether the active modal is a Document Properties tab. + fn properties_open(&self) -> bool { + matches!(self.h.state().dialog, Some(Dialog::Properties(_))) + } + + /// Replace an enabled property field through focus and keyboard events; never set its buffer directly. + fn type_text(&mut self, field: &str, value: &str) -> Result<(), String> { + if !self.properties_open() || !matches!(field, "Title" | "Author" | "Subject" | "Keywords" | "Name:" | "Value:") { + return Err("no editable property field".into()); + } + if value.len() > 65536 { + return Err("property input exceeds 64 KiB".into()); + } + { + let mut nodes = self.h.query_all_by_role_and_label(Role::TextInput, field); + let node = nodes.next().ok_or_else(|| format!("missing field: {field}"))?; + if nodes.next().is_some() || node.accesskit_node().is_disabled() { + return Err(format!("ambiguous or disabled field: {field}")); + } + node.focus(); + } + self.h.run_steps(1); + self.h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + self.h.run_steps(1); + self.h.key_press(egui::Key::Backspace); + self.h.event(egui::Event::Text(value.to_owned())); + Ok(()) + } + + /// Click one enabled button or radio control; reject unsupported or ambiguous targets. + fn click(&mut self, label: &str) -> Result<(), String> { + if !self.properties_open() { + return Err("properties dialog is closed".into()); + } + if !matches!( + label, + "OK" | "Cancel" + | "Close" + | "Description" + | "Initial View" + | "Custom" + | "Advanced" + | "Space Audit" + | "Add" + | "Update" + | "Delete" + | "Document Title" + | "File Name" + ) { + return Err("unsupported property action".into()); + } + let role = if matches!(label, "Document Title" | "File Name") { Role::RadioButton } else { Role::Button }; + let mut nodes = self.h.query_all_by_role_and_label(role, label); + let node = nodes.next().ok_or_else(|| format!("missing action: {label}"))?; + if nodes.next().is_some() || node.accesskit_node().is_disabled() { + return Err(format!("ambiguous or disabled action: {label}")); + } + node.click(); + Ok(()) + } + + /// Dispatch a semantic event through UI controls or the documented external-change engine path. + fn event(&mut self, event: Event) -> Result<(), String> { + match event { + Event::Open => { + self.active()?; + if self.properties_open() { + return Err("properties dialog is already open".into()); + } + self.h.state_mut().run_command("file.properties"); + } + Event::Type { field, value } => self.type_text(&field, &value)?, + Event::Click { label } => self.click(&label)?, + Event::Escape => self.h.key_press(egui::Key::Escape), + Event::ExternalEdit { key, value } => { + let id = self.active()?; + self.h.state_mut().session.apply(id, Edit::SetInfo { key, value }).map_err(|e| e.to_string())?; + } + Event::ExternalEditUndo { key, value } => { + let id = self.active()?; + self.h.state_mut().session.apply(id, Edit::SetInfo { key, value }).map_err(|e| e.to_string())?; + self.h.state_mut().session.undo(id).map_err(|e| e.to_string())?; + } + Event::Switch { document } => { + // Scenario document numbers are 1-based, like PdfCraft's automation pages. + let index = document.checked_sub(1).ok_or("document numbers start at 1")?; + if self.h.state().views.get(index).is_none() { + return Err("document is not open".into()); + } + self.h.state_mut().active = Some(index); + } + Event::Reload { title } => { + let id = self.active()?; + self.h.state_mut().session.reload(id, fixture(&title)?).map_err(|e| e.to_string())?; + } + Event::CloseDocument => { + let id = self.active()?; + let app = self.h.state_mut(); + app.session.close(id); + app.views.retain(|view| view.id != id); + app.active = (!app.views.is_empty()).then_some(0); + } + Event::Undo | Event::Redo => { + let id = self.active()?; + let doc = self.h.state().session.get(id).ok_or("document is not open")?; + let undo = matches!(event, Event::Undo); + if (if undo { doc.can_undo() } else { doc.can_redo() }).is_none() { + return Err("history action is unavailable".into()); + } + self.h.state_mut().run_command(if undo { "edit.undo" } else { "edit.redo" }); + } + Event::SaveReopen => { + let id = self.active()?; + let bytes = self.h.state().session.save_bytes(id).map_err(|e| e.to_string())?; + if let Some(path) = &self.artifacts { + std::fs::write(path.join("saved.pdf"), bytes.as_slice()).map_err(|e| e.to_string())?; + } + let mut fresh = Session::new(); + let reopened = fresh.open("reopened.pdf", None, bytes, None).map_err(|e| e.to_string())?; + self.reopened = fresh.get(reopened).map(document_state); + } + Event::Inspect => {} + } + Ok(()) + } + + /// Report engine and draft state after an event, retaining any action error for the runner to assert. + fn state(&self, error: Option) -> Value { + let app = self.h.state(); + let doc = app.active_ids().and_then(|(_, id)| app.session.get(id)); + let original_bytes = + doc.is_some_and(|doc| self.originals.iter().any(|(id, bytes)| *id == doc.id && bytes.as_slice() == doc.bytes.as_slice())); + let draft = app.props_draft.as_ref().map(|(_, fields)| { + let custom: serde_json::Map = + app.custom_draft.as_ref().into_iter().flat_map(|(_, d)| d.props.iter().map(|(k, v)| (k.clone(), json!(v)))).collect(); + json!({ + "title": fields.first(), "author": fields.get(1), "subject": fields.get(2), "keywords": fields.get(3), + "custom": custom, "display_title": app.view_draft.as_ref().map(|(_, v)| v.display_title), + }) + }); + json!({ + "error": error, "properties_open": self.properties_open(), "active": doc.map(|d| &d.name), + "notice": app.toast.as_ref().map(|(message, _)| message), + "document": doc.map(document_state), "draft": draft, "original_bytes": original_bytes, + "reopened": self.reopened, + }) + } + + /// Optionally render a local PNG, reporting renderer setup or file errors as failures. + fn snapshot(&mut self, step: u64) -> Result<(), String> { + let Some(path) = &self.artifacts else { return Ok(()) }; + // kittest's optional renderer panics when no GPU/software adapter is available. + // Report that setup failure without turning it into a successful scenario. + let image = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.h.render())) + .map_err(|_| "headless renderer failed; check GPU/software-renderer availability".to_owned())??; + image.save(path.join(format!("step-{step:03}.png"))).map_err(|e| e.to_string()) + } +} + +/// Serve bounded protocol-1 JSONL requests in order, returning one state per request until EOF. +fn main() -> Result<(), String> { + let mut args = std::env::args().skip(1); + let artifacts = match args.next().as_deref() { + None => None, + Some("--artifacts") => Some(PathBuf::from(args.next().ok_or("--artifacts needs a directory")?)), + _ => return Err("usage: properties_scenarios [--artifacts DIR]".into()), + }; + if args.next().is_some() { + return Err("unexpected command-line argument".into()); + } + let mut adapter = Adapter::new(artifacts)?; + let mut input = std::io::stdin().lock(); + let mut output = std::io::stdout().lock(); + let mut expected_id = 1_u64; + loop { + let mut line = String::new(); + let read = input.by_ref().take(LIMIT + 1).read_line(&mut line).map_err(|e| e.to_string())?; + if read == 0 { + break; + } + if read as u64 > LIMIT { + return Err("request exceeds 1 MiB".into()); + } + let request: Request = serde_json::from_str(&line).map_err(|e| e.to_string())?; + if request.protocol != 1 || request.id != expected_id { + return Err("expected protocol 1 and consecutive request IDs starting at 1".into()); + } + let error = adapter.event(request.event).err(); + // Tabs animate the modal's height and position. Advance a bounded 16 frames + // (about 267 ms of simulated time) before inspecting or targeting the next control. + adapter.h.run_steps(16); + adapter.snapshot(request.id)?; + serde_json::to_writer(&mut output, &json!({"protocol": 1, "id": request.id, "state": adapter.state(error)})).map_err(|e| e.to_string())?; + writeln!(output).map_err(|e| e.to_string())?; + output.flush().map_err(|e| e.to_string())?; + expected_id = expected_id.checked_add(1).ok_or("too many requests")?; + } + Ok(()) +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 759682fade..0e3313ce74 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -64,7 +64,7 @@ impl PdfCraftApp { } } - /// Construct an application with the default session, panels, theme and control state. + /// Construct default session, panels, theme and control state with no active property-edit session. pub fn new() -> Self { Self { panes: Default::default(), @@ -211,6 +211,7 @@ impl PdfCraftApp { attachment_description_draft: None, view_draft: None, custom_draft: None, + props_session: None, requests: Default::default(), #[cfg(not(target_arch = "wasm32"))] pickers: Default::default(), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b389ca431d..1d768e72a2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -29,7 +29,7 @@ mod tests; pub(crate) use prompts::{PromptButton, save_prompt_key, shorten_middle}; use prompts::{full_screen_prompt, link_prompt, password, remote_link_prompt, save_prompt, unlock}; -const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; +use crate::properties_session::INFO_KEYS; /// Drop (and zeroize) password drafts as soon as their owning dialog is inactive. /// Commands and control requests also call this: close/reopen can happen between frames. @@ -117,18 +117,19 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { if app.dialog != Some(Dialog::FieldTable) { app.field_table_draft = None; } + if !matches!(app.dialog, Some(Dialog::Properties(_))) { + app.discard_properties(); + } discard_inactive_password_drafts(app); // A movable window, not this modal: the page under it shows the table as it changes. if app.dialog == Some(Dialog::FieldTable) { crate::field_table_ui::show(app, ctx); return; } - let Some(dialog) = app.dialog else { - app.props_draft = None; - app.view_draft = None; - app.custom_draft = None; + let Some(dialog) = app.dialog else { return }; + if matches!(dialog, Dialog::Properties(_)) && !app.prepare_properties() { return; - }; + } // Properties ▸ Fonts shows the fonts as they are now, after any content edits. if let (Dialog::Properties(PropsTab::Fonts), Some((_, id))) = (dialog, app.active_ids()) { app.session.refresh_fonts(id); @@ -137,15 +138,6 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { if dialog == Dialog::Properties(PropsTab::SpaceAudit) { app.refresh_space_audit(); } - // Seed the editable Description fields from the document when the dialog opens. - if let (Dialog::Properties(_), Some((_, id))) = (dialog, app.active_ids()) - && app.props_draft.as_ref().is_none_or(|(d, _)| *d != id) - && let Some(doc) = app.session.get(id) - { - app.props_draft = Some((id, INFO_KEYS.map(|k| doc.info_value(k).unwrap_or_default()))); - app.view_draft = Some((id, doc.initial_view())); - app.custom_draft = Some((id, crate::CustomDraft { props: doc.custom_info(), ..Default::default() })); - } let mut req = Pending::default(); // Every dialog shares this modal. A different dialog than the one shown before starts from // its own size, not from the last one's: rows that fill the height they are offered would @@ -1793,7 +1785,8 @@ fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) /// Draw the active dialog's footer and record any requested action in `req`. fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { ui.add_space(16.0); - let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); + app.stage_properties(); + let changed = app.properties_changed(); // One row high: on its own, a right-to-left layout centres the buttons in all the height // left, which after a taller Properties tab is the room that tab left behind. ui.horizontal(|ui| { @@ -2196,10 +2189,8 @@ fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { if let Some(by) = req.split_now.take() { app.split_active(&by); } - if req.apply - && let Some(edits) = draft_changes(app) - { - app.apply_edit(Edit::Batch { label: "Change document properties".into(), edits }); + if req.apply && matches!(dialog, Dialog::Properties(_)) { + req.close = app.finish_properties(); } } @@ -2218,9 +2209,7 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: if dialog == Dialog::AttachmentDescription { app.attachment_description_draft = None; } - app.props_draft = None; - app.view_draft = None; - app.custom_draft = None; + app.discard_properties(); app.bulk_field_props = None; app.unmarked_page = Default::default(); } else { @@ -2261,37 +2250,6 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: } } -/// Info edits needed to make the document match the Description draft. -fn draft_changes(app: &PdfCraftApp) -> Option> { - let (id, draft) = app.props_draft.as_ref()?; - let doc = app.session.get(*id)?; - let mut edits: Vec = INFO_KEYS - .iter() - .zip(draft.iter()) - .filter(|(k, v)| doc.info_value(k).unwrap_or_default().trim() != v.trim()) - .map(|(k, v)| Edit::SetInfo { key: (*k).to_string(), value: v.clone() }) - .collect(); - if let Some((vid, v)) = &app.view_draft - && vid == id - && *v != doc.initial_view() - { - edits.push(Edit::SetInitialView(Box::new(v.clone()))); - } - // Custom: deleted properties are cleared, then added and changed ones are set. - if let Some((cid, d)) = &app.custom_draft - && cid == id - { - let before = doc.custom_info(); - for (k, _) in before.iter().filter(|(k, _)| !d.props.iter().any(|(n, _)| n == k)) { - edits.push(Edit::SetInfo { key: k.clone(), value: String::new() }); - } - for (k, v) in d.props.iter().filter(|(k, v)| !before.iter().any(|(n, w)| n == k && w == v)) { - edits.push(Edit::SetInfo { key: k.clone(), value: v.clone() }); - } - } - Some(edits) -} - fn yes(b: bool) -> String { tl!(if b { "Yes" } else { "No" }).to_string() } diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index f30edd8c86..a0e6a4938b 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1557,6 +1557,12 @@ fn date_languages_are_the_interface_languages() { assert_eq!(dates, interface); } +/// The stale-draft notice remains discoverable after the UI modules are reorganized. +#[test] +fn properties_stale_notice_is_in_translation_scan() { + assert!(ui_literals().contains("The document changed. Reopen Document Properties to edit the current version.")); +} + /// A report, not a check: the notifications pasting an image and pinning a tool show, in each /// language that has no translation for them yet (they show in English). #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7dfa39aa8c..deca4e34a5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -42,6 +42,7 @@ pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock mod commands; pub mod control; mod menus; +mod properties_session; /// Header & footer / watermark / background dialog types (tests and automation). pub mod marks { pub use crate::marks_ui::{MarksDraft, PageRange, Subset}; @@ -585,6 +586,8 @@ pub struct PdfCraftApp { pub view_draft: Option<(DocId, pdfcraft_engine::InitialView)>, /// Document Properties ▸ Custom being edited. pub custom_draft: Option<(DocId, CustomDraft)>, + /// Original values and document revision for the editable Properties tabs. + pub(crate) props_session: Option, /// Files picked asynchronously for combine / insert (web). pub requests: files::Requests, /// Native file pickers in flight (they never block the frame; see `pickers`). diff --git a/crates/ui-egui/src/properties_session.rs b/crates/ui-egui/src/properties_session.rs new file mode 100644 index 0000000000..44b23bd392 --- /dev/null +++ b/crates/ui-egui/src/properties_session.rs @@ -0,0 +1,259 @@ +//! Document Properties keeps its widget buffers local and commits one revision-bound draft. + +use edit_session::EditSession; +use pdfcraft_engine::{DocId, Document, Edit, InitialView}; + +use crate::{CustomDraft, PdfCraftApp}; + +pub(crate) const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; + +/// The small, accepted values shared by the Description, Initial View and Custom tabs. +#[derive(Clone, Debug)] +pub(crate) struct Values { + description: [String; 4], + view: InitialView, + custom: Vec<(String, String)>, +} + +impl PartialEq for Values { + /// Compare accepted values using the dialog's whitespace and unordered Custom-row rules. + fn eq(&self, other: &Self) -> bool { + // Preserve the dialog's existing whitespace/no-op rules, without stripping spaces + // from the text widgets while the user types. Custom row order is not a PDF edit. + self.description.iter().zip(&other.description).all(|(a, b)| a.trim() == b.trim()) + && self.view == other.view + && self.custom.len() == other.custom.len() + && self.custom.iter().all(|entry| other.custom.contains(entry)) + } +} + +impl Values { + /// Snapshot the editable values; PDF bytes and undo history remain owned by the engine. + fn read(doc: &Document) -> Self { + Self { description: INFO_KEYS.map(|key| doc.info_value(key).unwrap_or_default()), view: doc.initial_view(), custom: doc.custom_info() } + } + + /// Translate an accepted draft into existing engine edits, preserving unrelated PDF metadata. + fn edits_to(&self, after: &Self) -> Vec { + let mut edits: Vec = INFO_KEYS + .iter() + .zip(self.description.iter().zip(&after.description)) + .filter(|(_, (before, after))| before.trim() != after.trim()) + .map(|(key, (_, value))| Edit::SetInfo { key: (*key).into(), value: value.clone() }) + .collect(); + if self.view != after.view { + edits.push(Edit::SetInitialView(Box::new(after.view.clone()))); + } + for (key, _) in self.custom.iter().filter(|(key, _)| !after.custom.iter().any(|(name, _)| name == key)) { + edits.push(Edit::SetInfo { key: key.clone(), value: String::new() }); + } + for (key, value) in after.custom.iter().filter(|entry| !self.custom.contains(entry)) { + edits.push(Edit::SetInfo { key: key.clone(), value: value.clone() }); + } + edits + } +} + +/// Changes to permissions, Undo/Redo and in-place reloads invalidate the original draft too. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Context { + document: DocId, + generation: u64, + editable: bool, +} + +impl Context { + /// Capture the identity, monotonic generation and permission that bind the draft. + fn read(doc: &Document) -> Self { + Self { document: doc.id, generation: doc.edit_generation(), editable: doc.allows_modification() } + } +} + +pub(crate) type Draft = EditSession; + +impl PdfCraftApp { + /// Discard local form buffers and their session, without issuing an engine command. + pub(crate) fn discard_properties(&mut self) { + self.props_session = None; + self.props_draft = None; + self.view_draft = None; + self.custom_draft = None; + } + + /// Bind edits to one document revision. Clean inspection tabs may follow same-document + /// edits, preserving the existing live Fonts/Space Audit behavior without rebasing input. + pub(crate) fn prepare_properties(&mut self) -> bool { + self.stage_properties(); + let live = self.active_ids().and_then(|(_, id)| self.session.get(id)).map(|doc| (Values::read(doc), Context::read(doc))); + let mut stale = match (&self.props_session, &live) { + (Some(draft), Some((value, context))) => draft.check_live(value, context).is_err(), + (Some(_), None) => true, + (None, _) => false, + }; + let clean_inspector = matches!( + self.dialog, + Some(crate::Dialog::Properties( + crate::PropsTab::Fonts | crate::PropsTab::Advanced | crate::PropsTab::SpaceAudit | crate::PropsTab::Security + )) + ) && self.props_session.as_ref().is_some_and(|draft| { + draft.is_valid() + && draft.original() == draft.preview() + && live.as_ref().is_some_and(|(_, context)| context.document == draft.context().document) + }) && self.custom_draft.as_ref().is_some_and(|(_, custom)| custom.name.is_empty() && custom.value.is_empty()); + if stale && clean_inspector { + self.discard_properties(); + stale = false; + } + if stale || live.is_none() { + self.discard_properties(); + self.dialog = None; + if stale { + self.notify(tl!("The document changed. Reopen Document Properties to edit the current version.").to_string()); + } + return false; + } + if self.props_session.is_none() + && let Some((values, context)) = live + { + self.props_draft = Some((context.document, values.description.clone())); + self.view_draft = Some((context.document, values.view.clone())); + self.custom_draft = Some((context.document, CustomDraft { props: values.custom.clone(), ..Default::default() })); + self.props_session = Some(EditSession::new(values, context)); + } + true + } + + /// Stage widget values after their existing per-control validation. The Custom tab's + /// Name/Value input boxes are not accepted properties until Add or Update is pressed. + pub(crate) fn stage_properties(&mut self) { + let expected = self.props_session.as_ref().map(|draft| draft.context().document); + let proposal = match (&self.props_draft, &self.view_draft, &self.custom_draft) { + (Some((id, description)), Some((vid, view)), Some((cid, custom))) if Some(*id) == expected && id == vid && id == cid => { + Ok(Values { description: description.clone(), view: view.clone(), custom: custom.props.clone() }) + } + _ => Err(()), + }; + if let Some(draft) = &mut self.props_session { + let _ = draft.propose(proposal); + } + } + + /// Whether a valid proposal needs OK/Cancel instead of the unchanged dialog's Close button. + pub(crate) fn properties_changed(&self) -> bool { + self.props_session.as_ref().is_some_and(|draft| draft.is_valid() && draft.original() != draft.preview()) + } + + /// Check and consume once on the serialized UI command path. An engine failure retains + /// the widget values and restores the uncommitted session, so the user can correct them. + pub(crate) fn finish_properties(&mut self) -> bool { + if !self.prepare_properties() { + return true; + } + self.stage_properties(); + let Some((live, context)) = self.active_ids().and_then(|(_, id)| self.session.get(id)).map(|doc| (Values::read(doc), Context::read(doc))) + else { + return false; + }; + if self.props_session.as_ref().is_none_or(|draft| draft.check(&live, &context).is_err()) { + return false; + } + let Some(draft) = self.props_session.take() else { return false }; + match draft.finish(&live, &context) { + Ok(Some(change)) => { + let edits = change.before.edits_to(&change.after); + if self.apply_edit(Edit::Batch { label: "Change document properties".into(), edits }) { + true + } else { + // Session::apply is atomic on error; do not rebase to any newer document. + let mut retry = EditSession::new(change.before, context); + let _ = retry.propose::<()>(Ok(change.after)); + self.props_session = Some(retry); + false + } + } + Ok(None) => true, + Err(_) => { + self.discard_properties(); + self.notify(tl!("The document changed. Reopen Document Properties to edit the current version.").to_string()); + true + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Create a one-page document with a pending title edit for direct commit-path regressions. + fn app() -> PdfCraftApp { + let mut app = PdfCraftApp::new(); + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + app.open_bytes("properties.pdf", None, bytes.as_ref().clone()).unwrap(); + app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Description)); + assert!(app.prepare_properties()); + app.props_draft.as_mut().unwrap().1[0] = "Draft title".into(); + app + } + + #[test] + /// A concurrent edit must be rejected at commit even before another UI frame checks freshness. + fn commit_rechecks_revision_without_an_intervening_frame() { + let mut app = app(); + let (_, id) = app.active_ids().unwrap(); + app.session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Newer title".into() }).unwrap(); + let generation = app.session.get(id).unwrap().edit_generation(); + assert!(app.finish_properties()); + let doc = app.session.get(id).unwrap(); + assert_eq!(doc.info_value("Title").as_deref(), Some("Newer title")); + assert_eq!(doc.edit_generation(), generation); + assert!(app.props_session.is_none()); + assert!(app.props_draft.is_none()); + } + + #[test] + /// An invalid view must roll back the title too, retain input, and permit one corrected undo step. + fn engine_refusal_retains_draft_and_retry_is_one_atomic_edit() { + let mut app = app(); + let (_, id) = app.active_ids().unwrap(); + let original_bytes = app.session.get(id).unwrap().bytes.clone(); + let generation = app.session.get(id).unwrap().edit_generation(); + // The public control buffers can receive an invalid page even though the widget + // bounds its input. The engine rejects the entire title + view batch atomically. + app.view_draft.as_mut().unwrap().1.page = usize::MAX; + assert!(!app.finish_properties()); + let doc = app.session.get(id).unwrap(); + assert_eq!(doc.bytes, original_bytes); + assert_eq!(doc.info_value("Title"), None); + assert_eq!(doc.edit_generation(), generation); + assert!(!doc.dirty && doc.can_undo().is_none()); + assert_eq!(app.props_draft.as_ref().unwrap().1[0], "Draft title"); + assert!(app.props_session.is_some()); + + app.view_draft.as_mut().unwrap().1.page = 0; + assert!(app.finish_properties()); + assert_eq!(app.session.get(id).unwrap().info_value("Title").as_deref(), Some("Draft title")); + assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Change document properties")); + app.session.undo(id).unwrap(); + assert_eq!(app.session.get(id).unwrap().info_value("Title"), None); + assert!(app.session.get(id).unwrap().can_undo().is_none()); + } + + #[test] + /// Matching form-buffer identities must also belong to the session's original document. + fn buffers_from_another_document_cannot_commit() { + let mut app = app(); + let (_, id) = app.active_ids().unwrap(); + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + let other = app.session.open("other.pdf", None, bytes, None).unwrap(); + app.props_draft.as_mut().unwrap().0 = other; + app.view_draft.as_mut().unwrap().0 = other; + app.custom_draft.as_mut().unwrap().0 = other; + assert!(!app.finish_properties()); + for doc_id in [id, other] { + let doc = app.session.get(doc_id).unwrap(); + assert_eq!(doc.info_value("Title"), None); + assert!(!doc.dirty && doc.can_undo().is_none()); + } + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 50c17eaf25..ea5dec6e61 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -71,6 +71,7 @@ mod prepare; mod print; mod print_custom_paper_names; mod print_late_printer_list; +mod properties_session; mod protect; mod reading_colors; mod recent_files; diff --git a/crates/ui-egui/tests/it/properties_session.rs b/crates/ui-egui/tests/it/properties_session.rs new file mode 100644 index 0000000000..4b4566ad15 --- /dev/null +++ b/crates/ui-egui/tests/it/properties_session.rs @@ -0,0 +1,110 @@ +//! Properties drafts belong to the document revision on which the user opened them. + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_engine::Edit; +use pdfcraft_ui_egui::PdfCraftApp; + +/// Open the real Properties dialog and type a title that has not entered document history. +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + app.open_bytes("properties.pdf", None, bytes.as_ref().clone()).unwrap(); + app.set_option("dialog", "properties").unwrap(); + app + }); + h.run_steps(4); + h.get_by_role_and_label(Role::TextInput, "Title").focus(); + h.get_by_role_and_label(Role::TextInput, "Title").type_text("Old draft"); + h.run_steps(2); + h +} + +/// Reproduce the baseline stale commit; a guarded dialog has already dismissed itself. +fn confirm_if_still_open(h: &mut Harness<'static, PdfCraftApp>) { + // On the baseline this commits the stale text. A guarded dialog has already closed. + if h.state().dialog.is_some() { + h.get_by_label("OK").click(); + h.run_steps(3); + } +} + +#[test] +/// A pending title must never replace metadata written by a later document edit. +fn properties_session_does_not_overwrite_a_newer_document_edit() { + let mut h = harness(); + let id = h.state().views[0].id; + h.state_mut().session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Newer title".into() }).unwrap(); + let generation = h.state().session.get(id).unwrap().edit_generation(); + h.run_steps(2); + confirm_if_still_open(&mut h); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.info_value("Title").as_deref(), Some("Newer title")); + assert_eq!(doc.edit_generation(), generation, "stale confirmation issues no edit"); + assert!(h.state().dialog.is_none()); +} + +#[test] +/// Returning to the original value via Undo must still invalidate the draft and preserve Redo. +fn properties_session_rejects_undo_back_to_the_original_value() { + let mut h = harness(); + let id = h.state().views[0].id; + h.state_mut().session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Temporary".into() }).unwrap(); + h.state_mut().session.undo(id).unwrap(); + let generation = h.state().session.get(id).unwrap().edit_generation(); + h.run_steps(2); + confirm_if_still_open(&mut h); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.info_value("Title"), None); + assert_eq!(doc.edit_generation(), generation); + assert!(doc.can_redo().is_some(), "stale confirmation preserves Redo"); + assert!(h.state().dialog.is_none()); +} + +#[test] +/// Inspection refresh must not rebase accepted edits or Custom input awaiting Add. +fn properties_session_inspection_tabs_do_not_rebase_pending_input() { + for unaccepted_custom in [false, true] { + let mut h = harness(); + let id = h.state().views[0].id; + if unaccepted_custom { + // Name/Value are typed input, even before Add has accepted a property. + let app = h.state_mut(); + app.props_draft.as_mut().unwrap().1[0].clear(); + let custom = &mut app.custom_draft.as_mut().unwrap().1; + custom.name = "Project".into(); + custom.value = "Unaccepted".into(); + } + h.state_mut().set_option("dialog", "space-audit").unwrap(); + h.run_steps(3); + h.state_mut().session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Newer title".into() }).unwrap(); + let generation = h.state().session.get(id).unwrap().edit_generation(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert!(h.state().props_draft.is_none()); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.info_value("Title").as_deref(), Some("Newer title")); + assert_eq!(doc.edit_generation(), generation); + assert!(doc.custom_info().is_empty()); + } +} + +#[test] +/// A clean inspection tab keeps the dialog open and follows a same-document edit. +fn properties_session_clean_inspection_tab_follows_a_newer_edit() { + let mut h = harness(); + let id = h.state().views[0].id; + // No pending input: the typed title is cleared again before switching tabs. + h.state_mut().props_draft.as_mut().unwrap().1[0].clear(); + h.state_mut().set_option("dialog", "space-audit").unwrap(); + h.run_steps(3); + h.state_mut().session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Newer title".into() }).unwrap(); + let generation = h.state().session.get(id).unwrap().edit_generation(); + h.run_steps(3); + assert!(h.state().dialog.is_some(), "a clean inspection tab stays open"); + assert_eq!(h.state().props_draft.as_ref().unwrap().1[0], "Newer title", "the draft is reseeded from the newer revision"); + assert_eq!(h.state().session.get(id).unwrap().edit_generation(), generation, "refreshing issues no edit"); +} diff --git a/docs/document-properties-sessions.md b/docs/document-properties-sessions.md new file mode 100644 index 0000000000..93a905d970 --- /dev/null +++ b/docs/document-properties-sessions.md @@ -0,0 +1,100 @@ +# Document Properties sessions and interaction replay + +Status: proposed integration, updated 2026-11-10. + +Document Properties binds its Description, Initial View and accepted Custom values to +the document identity, edit generation and modification permission at opening. A +different document, reload, edit or Undo/Redo closes the stale dialog with a notice. +Returning to the original values through Undo still invalidates the old draft. +The notice is included in the translation-gap scan; catalogs without its translation +retain the standard English fallback. +Clean inspection tabs (Fonts, Advanced, Security, Space Audit) retain live refresh for +the same document; they never rebase accepted edits or pending Custom Name/Value input. + +[`edit-session`](https://github.com/PerchAnton/edit-session) 0.1.0-alpha.2 is a +dependency-free `no_std` library. The [vendor ledger](../vendor/README.md#edit-session) records the snapshot and its source revision. +It checks freshness and consumes a draft once. PdfCraft retains its form validation, +permission checks, PDF serialization and undo history. The UI bridge is +`crates/ui-egui/src/properties_session.rs`; it stores small property values, not PDF bytes. + +Typing does not edit the document. OK rechecks freshness on the serialized UI command +path and dispatches one existing `Edit::Batch`. An engine refusal leaves the draft open +for correction without applying part of the batch. Cancel/Escape, a no-op and stale +dismissal dispatch no edit. Description whitespace and Custom name validation retain +their existing behavior; Custom Name/Value input becomes part of the draft only through +Add/Update. No new Reset control or metadata field is introduced. + +The same document changes remain available through `Session::apply`, `doc_set_info` +and `doc_initial_view`. The existing opt-in `ui.set`, `ui.type`, `ui.click` and `ui.key` +control channel reaches the dialog. This patch adds no server or automation permission. +It covers Document Properties only; the broader dialog lifecycle issue #3917 and OS +open queueing are separate work. + +## Replaying the real dialog + +The consumer owns `crates/ui-egui/examples/properties_scenarios.rs`, built in PdfCraft's +workspace so it uses the same vendored patches and UI as the app. It is a development +example, not a shipped executable. It runs the real egui controls through `egui_kittest`, +then reports actual draft/document/history state as JSONL protocol 1. + +[`interaction-check`](https://github.com/PerchAnton/interaction-check) is a standard-library +Python runner for optional local replay. The commands below pin it to `7722d66c0ede7855ff58d1df3d1632fd4e5e66a5`. It sends only +events; expected values stay in the runner. Each scenario starts a fresh adapter process +with two synthetic, two-page PDFs. No corpus, network, proprietary fonts or committed +PDF fixture is required. + +From this repository, with Python 3.10+ and Rust 1.99.1: + +```sh +git clone https://github.com/PerchAnton/interaction-check ../interaction-check +git -C ../interaction-check checkout 7722d66c0ede7855ff58d1df3d1632fd4e5e66a5 +cargo build --locked -j 2 -p pdfcraft-ui-egui --example properties_scenarios +mkdir -p log/properties-interactions +PDFCRAFT_SYSTEM_FONTS=0 PDFCRAFT_LOCALE=en-US \ + python3 ../interaction-check/interaction_check.py --timeout 30 \ + --report log/properties-interactions/report.json \ + --junit log/properties-interactions/junit.xml \ + tests/interactions/document-properties/*.json -- target/debug/examples/properties_scenarios +``` + +On Windows, use `properties_scenarios.exe` and set environment variables with your shell. +The commands write JSON and JUnit reports under `log/properties-interactions/`. Replay runs headlessly. + +Events `open`, `type`, `click`, `escape`, `undo` and `redo` use actual UI commands/controls. +`external_edit`, `external_edit_undo`, `reload`, `switch` and `close_document` simulate +changes arriving outside that modal via the real session or tab selection. `save_reopen` +serializes with the engine and opens the resulting bytes in a fresh session. `inspect` +only advances frames and reports state. Document numbers are 1-based. Unsupported or +disabled controls return a state error; the scenarios assert the resulting state after each action. +Sixteen simulated frames after each event let the modal's animated layout settle. + +Ten scenarios cover cross-tab commit/save/reopen, one-step Undo/Redo, Cancel/Escape, +no-op with Redo, intervening edits, edit-then-Undo, same-ID reload, tab switch, target +close, Custom validation and live inspection without rebasing pending input. Rust tests additionally cover an engine refusal/retry and +the freshness check immediately before commit without an intervening UI frame. + +For optional local screenshots and `saved.pdf`, append `--artifacts DIR` after the +adapter executable while running **one** scenario. This requires an available GPU or +software renderer. Keep `PDFCRAFT_SYSTEM_FONTS=0`; generated artifacts stay under ignored +`log/`. The headless UI proves widget/engine behavior, not native OS input or PDF fidelity +in a separate reader. Existing renderer and interoperability tests remain necessary. + +## Dependency review + +The app builds from the vendored Rust source. The shared Cargo source rules apply to the entire lockfile. +The ordinary Rust suite exercises stale edits, Undo and inspection tabs. Running the extended replay is +an explicit developer choice. It uses a separate checkout and runs outside the app and its shared CI. +Maintainers can review the snapshot or choose to implement the small draft guard in PdfCraft itself. +This integration can be evaluated independently of craft-common-actions #84. + +## Revision history + +- 2026-11-10: reconcile current main's roadmap header; preserve both inactive password cleanup and the Properties draft guard. The vendored library and local replay contract are unchanged. Retain both new upstream print test modules alongside the Properties-session tests. +- 2026-11-09: reconcile current main, preserving the vendor snapshot, local replay, both projects' license notices and the Properties-session and current upstream test modules. + +- 2026-11-05: vendor the original Rust revision with its lifecycle tests and licenses; keep the ten-scenario Python replay local. + +- 2026-10-25: document the pending dependency decisions and shared release prerequisite; + add function documentation for the session bridge, adapter and regression tests, + and cover discovery of the stale notice by the translation scanner. +- 2026-10-25: initial Document Properties integration and real UI replay adapter. diff --git a/docs/roadmap.md b/docs/roadmap.md index 4b6c4dc656..1fe2b275f1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -245,7 +245,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Literal page-label prefix whitespace is preserved; nine public-inspection cases cover prefix spaces, encodings and empty-prefix controls | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | -| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). Initial-view window and interface options resolve indirect boolean preferences. Done since: #3309: Stack-safe named-destination pruning visits shared/cyclic indirect nodes once, without a name-count limit. Synthetic deep-tree and 60,000-name tests verify deletion, surviving targets, undo/redo, both save modes and reopening. Organize drags cancel after document edits and Undo/Redo (#3916 partial). Missing: page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). Initial-view window and interface options resolve indirect boolean preferences. Done since: #3309: Stack-safe named-destination pruning visits shared/cyclic indirect nodes once, without a name-count limit. Synthetic deep-tree and 60,000-name tests verify deletion, surviving targets, undo/redo, both save modes and reopening. Organize drags cancel after document edits and Undo/Redo (#3916 partial). Document Properties dismisses stale drafts after edits, reloads or tab changes; JSONL replay covers commit, cancellation and save/reopen. Missing: page transitions, recovery on the web. Maintainers are reviewing the vendored Document Properties runtime and the optional local scenario replay. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Refreshed against upstream `99941daa` (2026-10-15). Repeated page indices accumulate rotations from the latest local value (#1919); saved/reopened regressions retain inherited angles and siblings. Missing: page transitions, recovery on the web | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Missing: page transitions, recovery on the web | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | @@ -325,6 +325,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-11-09 · patch:** M4 #4594: reconcile upstream license notices, roadmap entries and UI test modules without changing the Document Properties draft guard or dependency policy. Estimates unchanged. + **2026-10-27 · minor:** M10 #5005: vector printing preserves annotation optional-content membership. Source resources and shared appearances remain unchanged. Estimates unchanged. @@ -337,7 +339,7 @@ Estimates unchanged. **2026-10-31 · minor:** M3/M4 #5330: direct quick-toolbar Crop pages action reuses command permissions and the existing crop tool. Estimates unchanged. **2026-10-26 · patch:** M2/PR #4172: reconcile current main and preserve upstream Japanese serif/weight selection alongside the installed CJK viewer and preview fallback. Estimates unchanged. - +- **2026-10-25 · minor:** Document Properties drafts now reject changed document identities/revisions, including reload and Undo/Redo, through pinned edit-session; real UI JSONL scenarios replay with interaction-check. Broader dialog invalidation remains separate (#3917); estimates unchanged. **2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. **2026-10-23 · minor:** M11: image downsampling uses physical resolution after page-local `/UserUnit` scaling, including shared nested forms. @@ -381,6 +383,7 @@ Estimates unchanged. | 2026-10-29 | minor | M7 #2517: widening a paragraph rejoins a wrap with the spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split, after an incremental save and a full rewrite. Estimates unchanged. | | 2026-10-29 | minor | M2: non-interpolated images drawn below their resolution are area-averaged to whole device pixels instead of nearest-neighbour (scanned text kept one sample in N per axis); page-scan-size images are smoothed when enlarged. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | +| 2026-10-25 | minor | M4, #4594 review: function documentation, stale-notice translation scanning and explicit dependency-policy/shared-release prerequisites; draft behavior and estimates unchanged. | | 2026-10-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. Estimates unchanged. | | 2026-10-25 | minor | M10: vector printing resolves annotation rectangle coordinates; synthetic regression evidence, estimates unchanged. | | 2026-10-26 | minor | M10: annotation rectangles, coordinates and appearance-state names follow up to 64 indirect references with cycle detection; standalone-object save/reopen and malformed-chain regressions, estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 056ee06db1..c337eee59d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3550,8 +3550,8 @@ milestone = "M3" status = "shipped" commands = ["file.properties"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft", "crates/organize/src/tests.rs::document_info_falls_back_to_the_xmp_packet", "crates/engine/src/tests.rs::document_information_stated_only_in_xmp_is_shown", "crates/ui-egui/tests/it/dialog_chrome.rs::document_properties_values_line_up_and_long_fields_wrap", "crates/ui-egui/tests/it/dialog_chrome.rs::space_audit_tab_measures_the_file_and_stays_in_the_card"] -notes = "Description, Initial View, Security, Fonts, Custom (organize.custom-properties) and Advanced, plus a Space Audit tab Acrobat does not have (its audit is only in the PDF Optimizer); the audit runs when the tab opens and again after an edit. Title, Author, Subject and Keywords (and Creator, Producer in doc_info) come from /Info, else from the XMP packet (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) for files that state them only there; edits write both. Missing: a UTF-16 XMP packet isn't read, and /Info kept only in an older revision's trailer isn't looked up" +evidence = ["crates/ui-egui/tests/it/properties_session.rs::properties_session_does_not_overwrite_a_newer_document_edit", "crates/ui-egui/tests/it/properties_session.rs::properties_session_rejects_undo_back_to_the_original_value", "crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft", "crates/organize/src/tests.rs::document_info_falls_back_to_the_xmp_packet", "crates/engine/src/tests.rs::document_information_stated_only_in_xmp_is_shown", "crates/ui-egui/tests/it/dialog_chrome.rs::document_properties_values_line_up_and_long_fields_wrap", "crates/ui-egui/tests/it/dialog_chrome.rs::space_audit_tab_measures_the_file_and_stays_in_the_card"] +notes = "Document Properties binds its draft to document identity, generation and permissions; newer edits, Undo/Redo and reload dismiss stale values before commit. Other dialogs are outside this guard. Description, Initial View, Security, Fonts, Custom (organize.custom-properties) and Advanced, plus a Space Audit tab Acrobat does not have (its audit is only in the PDF Optimizer); the audit runs when the tab opens and again after an edit. Title, Author, Subject and Keywords (and Creator, Producer in doc_info) come from /Info, else from the XMP packet (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) for files that state them only there; edits write both. Missing: a UTF-16 XMP packet isn't read, and /Info kept only in an older revision's trailer isn't looked up" [[feature]] id = "organize.edit-metadata" @@ -3562,8 +3562,8 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = ["doc_set_info"] -evidence = ["crates/organize/src/tests.rs::document_info_edits_round_trip_with_unicode", "crates/organize/src/tests.rs::document_info_edits_update_the_xmp_packet", "crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step"] -notes = "When the file has an XMP packet, the matching property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed or removed with the /Info entry; a packet that isn't UTF-8 is left as it was. Not yet: xmp:ModifyDate and xmp:MetadataDate on save, other languages of a title kept apart (the new value replaces them)." +evidence = ["crates/ui-egui/tests/it/properties_session.rs::properties_session_does_not_overwrite_a_newer_document_edit", "crates/ui-egui/tests/it/properties_session.rs::properties_session_rejects_undo_back_to_the_original_value", "crates/organize/src/tests.rs::document_info_edits_round_trip_with_unicode", "crates/organize/src/tests.rs::document_info_edits_update_the_xmp_packet", "crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step"] +notes = "Document Properties binds its draft to document identity, generation and permissions; newer edits, Undo/Redo and reload dismiss stale values before commit. Other dialogs are outside this guard. When the file has an XMP packet, the matching property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed or removed with the /Info entry; a packet that isn't UTF-8 is left as it was. Not yet: xmp:ModifyDate and xmp:MetadataDate on save, other languages of a title kept apart (the new value replaces them)." [[feature]] id = "organize.fonts-list" diff --git a/tests/interactions/document-properties/cancel-and-escape.json b/tests/interactions/document-properties/cancel-and-escape.json new file mode 100644 index 0000000000..c37b1f46f6 --- /dev/null +++ b/tests/interactions/document-properties/cancel-and-escape.json @@ -0,0 +1,38 @@ +{ + "schema": 1, + "name": "Document Properties: cancel-and-escape", + "steps": [ + { + "event": {"op": "open"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Title", "value": "Discard by Cancel"}, + "expect": {"error": null} + }, + { + "event": {"op": "click", "label": "Cancel"}, + "expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true} + }, + { + "event": {"op": "open"}, + "expect": {"error": null, "draft": {"title": "Original"}} + }, + { + "event": {"op": "type", "field": "Author", "value": "Discard by Escape"}, + "expect": {"error": null} + }, + { + "event": {"op": "escape"}, + "expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true} + }, + { + "event": {"op": "open"}, + "expect": {"error": null, "draft": {"author": "Original author"}} + }, + { + "event": {"op": "click", "label": "Close"}, + "expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true} + } + ] +} diff --git a/tests/interactions/document-properties/close-document.json b/tests/interactions/document-properties/close-document.json new file mode 100644 index 0000000000..124c398924 --- /dev/null +++ b/tests/interactions/document-properties/close-document.json @@ -0,0 +1,26 @@ +{ + "schema": 1, + "name": "Document Properties: close-document", + "steps": [ + { + "event": {"op": "open"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Title", "value": "Stale"}, + "expect": {"error": null} + }, + { + "event": {"op": "close_document"}, + "expect": {"error": null, "properties_open": false, "draft": null, "active": "B.pdf", "document": {"title": "Second document", "dirty": false, "undo": null, "redo": null}, "original_bytes": true, "notice": "The document changed. Reopen Document Properties to edit the current version."} + }, + { + "event": {"op": "open"}, + "expect": {"error": null, "draft": {"title": "Second document"}} + }, + { + "event": {"op": "click", "label": "Close"}, + "expect": {"error": null} + } + ] +} diff --git a/tests/interactions/document-properties/commit-save-undo.json b/tests/interactions/document-properties/commit-save-undo.json new file mode 100644 index 0000000000..308b001693 --- /dev/null +++ b/tests/interactions/document-properties/commit-save-undo.json @@ -0,0 +1,62 @@ +{ + "schema": 1, + "name": "Document Properties: commit-save-undo", + "steps": [ + { + "event": {"op": "open"}, + "expect": {"error": null, "properties_open": true, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true} + }, + { + "event": {"op": "type", "field": "Title", "value": "Preview 1"}, + "expect": {"error": null, "draft": {"title": "Preview 1"}, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true} + }, + { + "event": {"op": "type", "field": "Title", "value": "Published title"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Author", "value": "Review team"}, + "expect": {"error": null} + }, + { + "event": {"op": "click", "label": "Custom"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Name:", "value": "Project"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Value:", "value": "PdfCraft"}, + "expect": {"error": null} + }, + { + "event": {"op": "click", "label": "Add"}, + "expect": {"error": null, "draft": {"custom": {"Project": "PdfCraft", "Untouched": "Keep me"}}, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true} + }, + { + "event": {"op": "click", "label": "Initial View"}, + "expect": {"error": null} + }, + { + "event": {"op": "click", "label": "Document Title"}, + "expect": {"error": null, "draft": {"display_title": true}, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true} + }, + { + "event": {"op": "click", "label": "OK"}, + "expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Published title", "author": "Review team", "custom": {"Project": "PdfCraft", "Untouched": "Keep me"}, "display_title": true, "dirty": true, "undo": "Change document properties", "redo": null, "custom_count": 2}} + }, + { + "event": {"op": "undo"}, + "expect": {"error": null, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "undo": null, "redo": "Change document properties", "custom_count": 1}} + }, + { + "event": {"op": "redo"}, + "expect": {"error": null, "document": {"title": "Published title", "author": "Review team", "display_title": true, "undo": "Change document properties", "redo": null}} + }, + { + "event": {"op": "save_reopen"}, + "expect": {"error": null, "reopened": {"title": "Published title", "author": "Review team", "custom": {"Project": "PdfCraft", "Untouched": "Keep me"}, "display_title": true, "dirty": false, "pages": 2, "undo": null, "redo": null, "custom_count": 2}} + } + ] +} diff --git a/tests/interactions/document-properties/custom-validation.json b/tests/interactions/document-properties/custom-validation.json new file mode 100644 index 0000000000..aa330ac7dd --- /dev/null +++ b/tests/interactions/document-properties/custom-validation.json @@ -0,0 +1,46 @@ +{ + "schema": 1, + "name": "Document Properties: custom-validation", + "steps": [ + { + "event": {"op": "open"}, + "expect": {"error": null} + }, + { + "event": {"op": "click", "label": "Custom"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Name:", "value": "Title"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Value:", "value": "Not an accepted property"}, + "expect": {"error": null} + }, + { + "event": {"op": "click", "label": "Add"}, + "expect": {"error": "ambiguous or disabled action: Add", "properties_open": true, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true} + }, + { + "event": {"op": "type", "field": "Name:", "value": "Project"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Value:", "value": "Accepted"}, + "expect": {"error": null} + }, + { + "event": {"op": "click", "label": "Add"}, + "expect": {"error": null, "draft": {"custom": {"Untouched": "Keep me", "Project": "Accepted"}}, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true} + }, + { + "event": {"op": "click", "label": "OK"}, + "expect": {"error": null, "document": {"title": "Original", "custom": {"Untouched": "Keep me", "Project": "Accepted"}, "undo": "Change document properties", "custom_count": 2}} + }, + { + "event": {"op": "save_reopen"}, + "expect": {"error": null, "reopened": {"title": "Original", "custom": {"Untouched": "Keep me", "Project": "Accepted"}, "pages": 2, "custom_count": 2}} + } + ] +} diff --git a/tests/interactions/document-properties/inspection-tabs.json b/tests/interactions/document-properties/inspection-tabs.json new file mode 100644 index 0000000000..7f0b4a4028 --- /dev/null +++ b/tests/interactions/document-properties/inspection-tabs.json @@ -0,0 +1,58 @@ +{ + "schema": 1, + "name": "Document Properties: inspection tabs refresh only without pending input", + "steps": [ + { + "event": {"op": "open"}, + "expect": {"error": null} + }, + { + "event": {"op": "click", "label": "Space Audit"}, + "expect": {"error": null} + }, + { + "event": {"op": "external_edit", "key": "Title", "value": "Updated"}, + "expect": {"error": null, "properties_open": true, "draft": {"title": "Updated"}, "document": {"title": "Updated", "undo": "Change Title"}} + }, + { + "event": {"op": "click", "label": "Description"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Title", "value": "Pending"}, + "expect": {"error": null} + }, + { + "event": {"op": "click", "label": "Space Audit"}, + "expect": {"error": null} + }, + { + "event": {"op": "external_edit", "key": "Subject", "value": "New subject"}, + "expect": {"error": null, "properties_open": false, "draft": null, "notice": "The document changed. Reopen Document Properties to edit the current version.", "document": {"title": "Updated", "subject": "New subject", "undo": "Change Subject"}} + }, + { + "event": {"op": "open"}, + "expect": {"error": null} + }, + { + "event": {"op": "click", "label": "Custom"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Name:", "value": "Project"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Value:", "value": "Unaccepted"}, + "expect": {"error": null} + }, + { + "event": {"op": "click", "label": "Space Audit"}, + "expect": {"error": null} + }, + { + "event": {"op": "external_edit", "key": "Author", "value": "New author"}, + "expect": {"error": null, "properties_open": false, "draft": null, "notice": "The document changed. Reopen Document Properties to edit the current version.", "document": {"title": "Updated", "author": "New author", "custom": {"Untouched": "Keep me"}, "custom_count": 1}} + } + ] +} diff --git a/tests/interactions/document-properties/newer-edit.json b/tests/interactions/document-properties/newer-edit.json new file mode 100644 index 0000000000..4ec2fb9b0a --- /dev/null +++ b/tests/interactions/document-properties/newer-edit.json @@ -0,0 +1,30 @@ +{ + "schema": 1, + "name": "Document Properties: newer-edit", + "steps": [ + { + "event": {"op": "open"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Title", "value": "Stale"}, + "expect": {"error": null} + }, + { + "event": {"op": "external_edit", "key": "Title", "value": "Newer"}, + "expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Newer", "undo": "Change Title", "redo": null}, "notice": "The document changed. Reopen Document Properties to edit the current version."} + }, + { + "event": {"op": "open"}, + "expect": {"error": null, "draft": {"title": "Newer"}} + }, + { + "event": {"op": "click", "label": "Close"}, + "expect": {"error": null} + }, + { + "event": {"op": "undo"}, + "expect": {"error": null, "document": {"title": "Original", "undo": null}} + } + ] +} diff --git a/tests/interactions/document-properties/no-op-preserves-redo.json b/tests/interactions/document-properties/no-op-preserves-redo.json new file mode 100644 index 0000000000..7d52e8d46a --- /dev/null +++ b/tests/interactions/document-properties/no-op-preserves-redo.json @@ -0,0 +1,30 @@ +{ + "schema": 1, + "name": "Document Properties: no-op-preserves-redo", + "steps": [ + { + "event": {"op": "external_edit_undo", "key": "Subject", "value": "Future"}, + "expect": {"error": null, "document": {"title": "Original", "subject": null, "undo": null, "redo": "Change Subject"}} + }, + { + "event": {"op": "open"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Title", "value": "Transient"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Title", "value": " Original "}, + "expect": {"error": null, "draft": {"title": " Original "}, "document": {"title": "Original", "undo": null, "redo": "Change Subject"}} + }, + { + "event": {"op": "click", "label": "Close"}, + "expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Original", "undo": null, "redo": "Change Subject"}} + }, + { + "event": {"op": "redo"}, + "expect": {"error": null, "document": {"subject": "Future"}} + } + ] +} diff --git a/tests/interactions/document-properties/reload-same-document.json b/tests/interactions/document-properties/reload-same-document.json new file mode 100644 index 0000000000..9d39352de8 --- /dev/null +++ b/tests/interactions/document-properties/reload-same-document.json @@ -0,0 +1,34 @@ +{ + "schema": 1, + "name": "Document Properties: reload-same-document", + "steps": [ + { + "event": {"op": "open"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Title", "value": "Stale"}, + "expect": {"error": null} + }, + { + "event": {"op": "reload", "title": "Reloaded"}, + "expect": {"error": null, "properties_open": false, "draft": null, "active": "A.pdf", "document": {"title": "Reloaded", "dirty": false, "undo": null, "redo": null}, "notice": "The document changed. Reopen Document Properties to edit the current version."} + }, + { + "event": {"op": "open"}, + "expect": {"error": null, "draft": {"title": "Reloaded"}} + }, + { + "event": {"op": "type", "field": "Title", "value": "After reload"}, + "expect": {"error": null} + }, + { + "event": {"op": "click", "label": "OK"}, + "expect": {"error": null, "document": {"title": "After reload"}} + }, + { + "event": {"op": "undo"}, + "expect": {"error": null, "document": {"title": "Reloaded", "undo": null}} + } + ] +} diff --git a/tests/interactions/document-properties/switch-document.json b/tests/interactions/document-properties/switch-document.json new file mode 100644 index 0000000000..1426c11db0 --- /dev/null +++ b/tests/interactions/document-properties/switch-document.json @@ -0,0 +1,30 @@ +{ + "schema": 1, + "name": "Document Properties: switch-document", + "steps": [ + { + "event": {"op": "open"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Title", "value": "Stale"}, + "expect": {"error": null} + }, + { + "event": {"op": "switch", "document": 2}, + "expect": {"error": null, "properties_open": false, "draft": null, "active": "B.pdf", "document": {"title": "Second document", "dirty": false, "undo": null, "redo": null}, "original_bytes": true, "notice": "The document changed. Reopen Document Properties to edit the current version."} + }, + { + "event": {"op": "switch", "document": 1}, + "expect": {"error": null, "active": "A.pdf", "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true} + }, + { + "event": {"op": "open"}, + "expect": {"error": null, "draft": {"title": "Original"}} + }, + { + "event": {"op": "click", "label": "Close"}, + "expect": {"error": null, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}} + } + ] +} diff --git a/tests/interactions/document-properties/undo-aba.json b/tests/interactions/document-properties/undo-aba.json new file mode 100644 index 0000000000..d87676df8b --- /dev/null +++ b/tests/interactions/document-properties/undo-aba.json @@ -0,0 +1,22 @@ +{ + "schema": 1, + "name": "Document Properties: undo-aba", + "steps": [ + { + "event": {"op": "open"}, + "expect": {"error": null} + }, + { + "event": {"op": "type", "field": "Title", "value": "Stale"}, + "expect": {"error": null} + }, + { + "event": {"op": "external_edit_undo", "key": "Title", "value": "Temporary"}, + "expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Original", "undo": null, "redo": "Change Title"}, "notice": "The document changed. Reopen Document Properties to edit the current version."} + }, + { + "event": {"op": "redo"}, + "expect": {"error": null, "document": {"title": "Temporary", "undo": "Change Title", "redo": null}} + } + ] +} diff --git a/vendor/README.md b/vendor/README.md index 135101d4ac..391dab88e2 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -1,5 +1,21 @@ # Vendored crates +## edit-session + +Document Properties uses the dependency-free draft state machine from +[edit-session 0.1.0-alpha.2](https://github.com/PerchAnton/edit-session/tree/033f7c3a67b825b8f222288788cc61234c0c945d). +The manifest, library, lifecycle tests and both licenses match that revision byte for byte. +The local README links here and rustfmt settings preserve upstream formatting. PdfCraft owns document identity, permissions and Undo. +Review upstream changes before replacing the snapshot and run the lifecycle and Document Properties tests. + +| File | SHA-256 | +| --- | --- | +| [Cargo.toml](edit-session/Cargo.toml) | `19d3c1ee04b087a12975732b76f8c3bb14d2b158ec48dfa4dadc54aa73e964ba` | +| [src/lib.rs](edit-session/src/lib.rs) | `b86bab15e5787bb2442ebf7aa17cadb75d18bbd10f1abd7a870a9a4bb2af66e3` | +| [tests/lifecycle.rs](edit-session/tests/lifecycle.rs) | `34930f3942721491f8c96f251bcdf3304f9eddf39bcf0469334ab66a89c94e2a` | +| [LICENSE-MIT](edit-session/LICENSE-MIT) | `665a942f9317a358930b470ad522a22a58ff6c346db0395463ec84dd3e19d775` | +| [LICENSE-APACHE](edit-session/LICENSE-APACHE) | `cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30` | + ## Window-stack provenance after the egui 0.37 migration The checked-in, resolved Cargo graph pins `winit` to storytold/winit diff --git a/vendor/edit-session/Cargo.toml b/vendor/edit-session/Cargo.toml new file mode 100644 index 0000000000..972400d7f8 --- /dev/null +++ b/vendor/edit-session/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "edit-session" +version = "0.1.0-alpha.2" +edition = "2024" +rust-version = "1.88" +description = "Toolkit-independent preview, validation and commit lifecycle" +license = "MIT OR Apache-2.0" +repository = "https://github.com/PerchAnton/edit-session" +readme = "README.md" +publish = false + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" diff --git a/vendor/edit-session/LICENSE-APACHE b/vendor/edit-session/LICENSE-APACHE new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/vendor/edit-session/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/edit-session/LICENSE-MIT b/vendor/edit-session/LICENSE-MIT new file mode 100644 index 0000000000..a70cb3c704 --- /dev/null +++ b/vendor/edit-session/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 PerchAnton + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/edit-session/README.md b/vendor/edit-session/README.md new file mode 100644 index 0000000000..4efe2b2f6e --- /dev/null +++ b/vendor/edit-session/README.md @@ -0,0 +1,4 @@ +# edit-session in PdfCraft + +The Document Properties dialog uses this local copy of edit-session 0.1.0-alpha.2. +The [vendor ledger](../README.md#edit-session) records its upstream revision, licenses and verification steps. diff --git a/vendor/edit-session/rustfmt.toml b/vendor/edit-session/rustfmt.toml new file mode 100644 index 0000000000..7f62bab337 --- /dev/null +++ b/vendor/edit-session/rustfmt.toml @@ -0,0 +1,3 @@ +# Preserve upstream formatting in this snapshot. +max_width = 100 +use_small_heuristics = "Default" diff --git a/vendor/edit-session/src/lib.rs b/vendor/edit-session/src/lib.rs new file mode 100644 index 0000000000..46882f472d --- /dev/null +++ b/vendor/edit-session/src/lib.rs @@ -0,0 +1,168 @@ +//! A local draft of a value, independent of rendering and the application's undo engine. +//! +//! The host validates proposals and owns all document mutations. Dropping a session cancels +//! only its local draft. Store small parameters here, not a full document per pointer event. +//! A context must include stable document/target identity and a revision that changes on +//! every relevant edit (including undo/redo). Comparing values alone cannot detect ABA edits. +#![no_std] +#![forbid(unsafe_code)] + +/// Why a draft cannot be committed to the current host state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FinishError { + /// The most recent proposal was rejected. Correct it before finishing. + InvalidProposal, + /// Document, target, selection or relevant revision has changed. + ContextChanged, + /// The live value changed without the context being updated. + ValueChanged, +} + +impl core::fmt::Display for FinishError { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter.write_str(match self { + Self::InvalidProposal => "the latest proposal is invalid", + Self::ContextChanged => "the edit context has changed", + Self::ValueChanged => "the live value has changed", + }) + } +} + +impl core::error::Error for FinishError {} + +/// A proposed change. The caller must apply it through its existing command engine. +#[derive(Debug, PartialEq, Eq)] +pub struct Change { + pub before: T, + pub after: T, +} + +/// A single-use edit. It never writes to a document or records history itself. +/// +/// This type intentionally does not implement `Clone`. `finish` consumes it, so the same +/// session cannot accidentally emit two commits. A failed finish also consumes the draft; +/// call `check` first if invalid input should remain editable in the UI. +#[must_use = "a draft must be retained, finished, or deliberately cancelled"] +#[derive(Debug)] +pub struct EditSession { + original: T, + preview: T, + context: C, + valid: bool, +} + +impl EditSession { + /// Begin from a value already validated by the application. + pub fn new(original: T, context: C) -> Self { + Self { + preview: original.clone(), + original, + context, + valid: true, + } + } + + /// Restore the original preview and clear invalid input, keeping the draft open. + /// + /// The original value and context are retained: this does not rebase a stale draft + /// or change the live document. The host must refresh its displayed preview itself. + pub fn reset(&mut self) { + self.preview = self.original.clone(); + self.valid = true; + } +} + +impl EditSession { + /// The validated value captured when editing began. + pub fn original(&self) -> &T { + &self.original + } + + /// Last valid preview, even when a later incomplete/invalid input is present. + pub fn preview(&self) -> &T { + &self.preview + } + + /// The host context captured when editing began; never changed by this session. + pub fn context(&self) -> &C { + &self.context + } + + /// Whether the latest proposal was accepted. This does not check live host state. + pub fn is_valid(&self) -> bool { + self.valid + } + + /// Accept a host-validated proposal. Errors invalidate commit without replacing the + /// last valid preview. Units, mixed values, finite checks and bounds belong to the host. + pub fn propose(&mut self, proposal: Result) -> Result<(), E> { + match proposal { + Ok(value) => { + self.preview = value; + self.valid = true; + Ok(()) + } + Err(error) => { + self.valid = false; + Err(error) + } + } + } + + /// Discard the local draft. No rollback commands or history entries are generated. + pub fn cancel(self) {} +} + +impl EditSession { + /// Check whether the draft still belongs to the current host state, independently + /// of invalid input. Useful when deciding whether an open editor must be dismissed. + /// + /// This never returns `InvalidProposal` and does not authorize a commit. Use `check` + /// or `finish` immediately before applying on the host's serialized command path. + /// Context conflicts take precedence over value conflicts. No lock is acquired. + pub fn check_live(&self, live: &T, context: &C) -> Result<(), FinishError> { + if *context != self.context { + Err(FinishError::ContextChanged) + } else if *live != self.original { + Err(FinishError::ValueChanged) + } else { + Ok(()) + } + } + + /// Check live host state and proposal validity immediately before applying on the + /// host's serialized command path. Live conflicts take precedence over invalid input. + /// This comparison is not a lock and is not a distributed transaction. + pub fn check(&self, live: &T, context: &C) -> Result<(), FinishError> { + self.check_live(live, context)?; + if !self.valid { + Err(FinishError::InvalidProposal) + } else { + Ok(()) + } + } + + /// Emit at most one change; a no-op emits none, preserving the host's redo stack. + /// The host still validates and applies the command and handles execution failure. + pub fn finish(self, live: &T, context: &C) -> Result>, FinishError> { + self.check(live, context)?; + if self.original == self.preview { + Ok(None) + } else { + Ok(Some(Change { + before: self.original, + after: self.preview, + })) + } + } +} + +/// A session cannot be finished twice. +/// +/// ```compile_fail +/// use edit_session::EditSession; +/// let draft = EditSession::new(100, ("document", 7)); +/// let _ = draft.finish(&100, &("document", 7)); +/// let _ = draft.finish(&100, &("document", 7)); +/// ``` +const _: () = (); diff --git a/vendor/edit-session/tests/lifecycle.rs b/vendor/edit-session/tests/lifecycle.rs new file mode 100644 index 0000000000..7d621dd667 --- /dev/null +++ b/vendor/edit-session/tests/lifecycle.rs @@ -0,0 +1,180 @@ +use edit_session::{Change, EditSession, FinishError}; + +#[test] +fn many_previews_produce_one_final_change() { + let mut draft = EditSession::new(100, ("photo", "layer-1", 9)); + for value in 0..1000 { + assert_eq!(draft.propose::<()>(Ok(value)), Ok(())); + assert_eq!(*draft.original(), 100); + } + assert_eq!( + draft.finish(&100, &("photo", "layer-1", 9)), + Ok(Some(Change { + before: 100, + after: 999 + })) + ); +} + +#[test] +fn invalid_input_retains_preview_but_blocks_commit_until_corrected() { + let mut draft = EditSession::new(10, 0); + assert_eq!(draft.propose::<&str>(Ok(12)), Ok(())); + assert_eq!( + draft.propose(Err("incomplete number")), + Err("incomplete number") + ); + assert_eq!(*draft.preview(), 12); + assert!(!draft.is_valid()); + assert_eq!(draft.check(&10, &0), Err(FinishError::InvalidProposal)); + assert_eq!(draft.propose::<&str>(Ok(14)), Ok(())); + assert_eq!( + draft.finish(&10, &0), + Ok(Some(Change { + before: 10, + after: 14 + })) + ); +} + +#[test] +fn unchanged_and_return_to_original_have_no_commit() { + assert_eq!(EditSession::new(20, 0).finish(&20, &0), Ok(None)); + let mut draft = EditSession::new(20, 0); + assert_eq!(draft.propose::<()>(Ok(50)), Ok(())); + assert_eq!(draft.propose::<()>(Ok(20)), Ok(())); + assert_eq!(draft.finish(&20, &0), Ok(None)); +} + +#[test] +fn stale_document_target_or_revision_cannot_commit() { + for context in [(2, 5, 8), (1, 6, 8), (1, 5, 9)] { + let draft = EditSession::new(100, (1, 5, 8)); + assert_eq!( + draft.finish(&100, &context), + Err(FinishError::ContextChanged) + ); + } +} + +#[test] +fn unversioned_value_change_is_also_rejected() { + assert_eq!( + EditSession::new(100, 1).finish(&50, &1), + Err(FinishError::ValueChanged) + ); +} + +#[test] +fn undo_back_to_equal_value_is_stale_when_revision_has_advanced() { + assert_eq!( + EditSession::new(100, 1).finish(&100, &3), + Err(FinishError::ContextChanged) + ); +} + +#[test] +fn invalid_final_input_does_not_silently_commit_last_valid_value() { + let mut draft = EditSession::new(10, 0); + assert_eq!(draft.propose::<&str>(Ok(12)), Ok(())); + assert!(draft.propose(Err("not finite")).is_err()); + assert_eq!(draft.finish(&10, &0), Err(FinishError::InvalidProposal)); +} + +#[test] +fn optional_values_preserve_inheritance_instead_of_flattening_it() { + let mut draft = EditSession::new(None::, 0); + assert_eq!(draft.propose::<()>(Ok(Some(20))), Ok(())); + assert_eq!(draft.propose::<()>(Ok(None)), Ok(())); + assert_eq!(draft.finish(&None, &0), Ok(None)); +} + +#[test] +fn cancel_does_not_touch_host_value() { + let live = String::from("original"); + let mut draft = EditSession::new(live.clone(), 1); + assert_eq!(draft.propose::<()>(Ok("preview".into())), Ok(())); + draft.cancel(); + assert_eq!(live, "original"); +} + +#[test] +fn invalid_input_can_remain_editable_while_live_context_is_current() { + let mut draft = EditSession::new(10, 7); + assert!(draft.propose(Err("incomplete number")).is_err()); + assert_eq!(draft.check_live(&10, &7), Ok(())); + assert_eq!(draft.check(&10, &7), Err(FinishError::InvalidProposal)); + for (live, context, error) in [ + (10, 8, FinishError::ContextChanged), + (11, 8, FinishError::ContextChanged), + (11, 7, FinishError::ValueChanged), + ] { + assert_eq!(draft.check_live(&live, &context), Err(error)); + assert_eq!(draft.check(&live, &context), Err(error)); + } + assert!(!draft.is_valid()); +} + +#[test] +fn reset_clears_invalid_input_and_confirming_it_is_a_noop() { + // Reset only needs the value to be Clone, not the host's context. + #[derive(Debug, PartialEq)] + struct Context(u64); + let mut draft = EditSession::new(String::from("original"), Context(7)); + assert_eq!(draft.propose::<()>(Ok(String::from("preview"))), Ok(())); + assert!(draft.propose(Err("invalid")).is_err()); + draft.reset(); + assert!(draft.is_valid()); + assert_eq!(draft.preview(), "original"); + assert_eq!(draft.original(), "original"); + assert_eq!(draft.context(), &Context(7)); + assert_eq!( + draft.finish(&String::from("original"), &Context(7)), + Ok(None) + ); +} + +#[test] +fn reset_allows_further_editing_without_changing_the_original() { + let mut draft = EditSession::new(10, 7); + assert_eq!(draft.propose::<()>(Ok(12)), Ok(())); + draft.reset(); + draft.reset(); + assert_eq!(draft.propose::<()>(Ok(15)), Ok(())); + assert_eq!( + draft.finish(&10, &7), + Ok(Some(Change { + before: 10, + after: 15 + })) + ); +} + +#[test] +fn reset_does_not_rebase_a_stale_draft_or_hide_a_live_value_conflict() { + for (live, context, error) in [ + (10, 9, FinishError::ContextChanged), // Edit then Undo back to the original. + (12, 7, FinishError::ValueChanged), + ] { + let mut draft = EditSession::new(10, 7); + assert_eq!(draft.propose::<()>(Ok(12)), Ok(())); + draft.reset(); + assert_eq!(draft.finish(&live, &context), Err(error)); + } +} + +#[test] +fn finish_errors_support_standard_error_reporting() { + for (error, message) in [ + ( + FinishError::InvalidProposal, + "the latest proposal is invalid", + ), + (FinishError::ContextChanged, "the edit context has changed"), + (FinishError::ValueChanged, "the live value has changed"), + ] { + let reported: &dyn core::error::Error = &error; + assert_eq!(reported.to_string(), message); + assert!(reported.source().is_none()); + } +}