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.
This commit is contained in:
Perchenko Anton authored and GitHub committed 2026-11-10 20:22:58 +01:00
1 parent 8e6ec925c8
commit d47adeff89
35 files changed
+1832 -62

No files matched your search

Generated
+5
View File
@@ -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",
+2
View File
@@ -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:`.
+13 -1
View File
@@ -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"
+2
View File
@@ -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.
+7
View File
@@ -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.
+1
View File
@@ -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 }
@@ -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<Arc<Vec<u8>>, 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<String, Value> = 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<Vec<u8>>)>,
reopened: Option<Value>,
artifacts: Option<PathBuf>,
}
impl Adapter {
/// Open two independent fixtures and create a deterministic 60 Hz headless UI harness.
fn new(artifacts: Option<PathBuf>) -> Result<Self, String> {
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<DocId, String> {
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<String>) -> 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<String, Value> =
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(())
}
+2 -1
View File
@@ -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(),
+12 -54
View File
@@ -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<Vec<Edit>> {
let (id, draft) = app.props_draft.as_ref()?;
let doc = app.session.get(*id)?;
let mut edits: Vec<Edit> = 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()
}
+6
View File
@@ -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]
+3
View File
@@ -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<properties_session::Draft>,
/// Files picked asynchronously for combine / insert (web).
pub requests: files::Requests,
/// Native file pickers in flight (they never block the frame; see `pickers`).
+259
View File
@@ -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<Edit> {
let mut edits: Vec<Edit> = 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<Values, Context>;
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());
}
}
}
+1
View File
@@ -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;
@@ -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");
}
+100
View File
@@ -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.
+5 -2
View File
@@ -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<br>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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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. |
+4 -4
View File
@@ -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"
@@ -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}
}
]
}
@@ -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}
}
]
}
@@ -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}}
}
]
}
@@ -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}}
}
]
}
@@ -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}}
}
]
}
@@ -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}}
}
]
}
@@ -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"}}
}
]
}
@@ -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}}
}
]
}
@@ -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}}
}
]
}
@@ -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}}
}
]
}
+16
View File
@@ -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
+18
View File
@@ -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"
+202
View File
@@ -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.
+21
View File
@@ -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.
+4
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
# Preserve upstream formatting in this snapshot.
max_width = 100
use_small_heuristics = "Default"
+168
View File
@@ -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<T> {
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<T, C> {
original: T,
preview: T,
context: C,
valid: bool,
}
impl<T: Clone, C> EditSession<T, C> {
/// 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<T, C> EditSession<T, C> {
/// 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<E>(&mut self, proposal: Result<T, E>) -> 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<T: PartialEq, C: PartialEq> EditSession<T, C> {
/// 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<Option<Change<T>>, 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 _: () = ();
+180
View File
@@ -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::<u16>, 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());
}
}