M4: keep the last saved revision across undo (#1310, #1311, #1312) (#1435)

* M4: keep the last saved revision across undo (#1310, #1311, #1312)

Undo treated a return to the opened file as unsaved work, and after a save it followed the restored snapshot's older base. Revert and the next save now stay on the last saved file, so that revision remains in the PDF.

* Run a tool after a background open of the file it asked for

A tool chosen with no document opened the picked file on the worker, but only ran when that open finished inside the picker callback. Remember the command on the pending open, and let the command test wait for the worker.

* Wait for background opens in the command tests

Opening a picked file or a recent file finishes on the worker, so a fixed handful of frames can assert before the tab exists.

* Compare batch copies to the PDF that was written

A new text PDF stamps the current second, so a later pdf() call is a different file. The numbered copy and the source must match the bytes that were saved.

* Drop the unused batch fixture left by the merge

Main already keeps the written PDF in original, so the older source binding is unread and fails -D warnings.
This commit is contained in:
Márk Bartalos
2026-10-15 02:17:06 -04:00
committed by GitHub
parent 8a04b6d5c2
commit b89cbd0044
6 changed files with 175 additions and 16 deletions
+2
View File
@@ -3,6 +3,7 @@
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -152,6 +153,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged.
- **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged.
- **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged.
- **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged.
- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged.
+43
View File
@@ -914,6 +914,49 @@ impl Document {
self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1)));
}
/// Record `current`'s object graph as unsaved edits of this document.
///
/// Used when the working snapshot was restored from an earlier revision and the next
/// incremental save must still append to this file (the last saved revision). Objects
/// that already match are left untouched. Reads go through private readers so neither
/// document's editing cache is filled.
pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> {
let saved_nums = self.object_numbers();
let current_nums = current.object_numbers();
let mut freed = Vec::new();
let mut changed = Vec::new();
{
let mut saved_reader = self.object_reader();
let mut current_reader = current.object_reader();
for &num in &saved_nums {
if current_nums.binary_search(&num).is_err() {
freed.push(num);
}
}
for &num in &current_nums {
let generation = current.generation(num);
let same = saved_nums.binary_search(&num).is_ok()
&& self.generation(num) == generation
&& saved_reader.try_get(ObjRef::new(num, 0))? == current_reader.try_get(ObjRef::new(num, 0))?;
if !same {
changed.push(num);
}
}
}
for num in freed {
let generation = self.generation(num);
self.free(ObjRef::new(num, generation));
}
let mut current_reader = current.object_reader();
for num in changed {
let generation = current.generation(num);
let object = current_reader.try_get(ObjRef::new(num, 0))?;
self.set(ObjRef::new(num, generation), (*object).clone());
}
*self.trailer_mut() = current.trailer().clone();
Ok(())
}
/// Edit a dictionary (or stream dictionary) object in place.
pub fn update_dict(&mut self, r: ObjRef, f: impl FnOnce(&mut Dict)) -> Result<(), CosError> {
let mut obj = (*self.get(r)).clone();
+47 -13
View File
@@ -247,6 +247,11 @@ type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope);
#[derive(Clone)]
struct Editor {
cos: pdfcraft_cos::Document,
/// The file as last opened or saved. Incremental saves append to it, and Revert opens it.
/// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it.
saved: Arc<Vec<u8>>,
/// Passwords that open [`Self::saved`].
saved_keys: Keys,
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
keys: Keys,
@@ -743,9 +748,9 @@ impl Document {
}
/// The file as last opened or saved, without unsaved edits: what an incremental save appends
/// to.
/// to. This stays that file when undo has restored an older snapshot.
pub fn saved_bytes(&self) -> Arc<Vec<u8>> {
self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.cos.bytes().clone())
self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone())
}
/// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices).
@@ -3205,9 +3210,12 @@ impl Session {
let (mut editor, read_only_reason) = match cos {
Ok(Ok(cos)) => {
let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) };
let saved = cos.bytes().clone();
(
Some(Editor {
cos,
saved,
saved_keys: keys.clone(),
undo: Vec::new(),
redo: Vec::new(),
keys,
@@ -3483,7 +3491,7 @@ impl Session {
editor.redo.push((label.clone(), current, current_keys, scope));
Self::adopt_keys(doc);
Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?;
doc.dirty = true;
Self::sync_dirty(doc);
doc.generation += 1;
Ok(label)
}
@@ -3498,11 +3506,18 @@ impl Session {
editor.undo.push((label.clone(), current, current_keys, scope));
Self::adopt_keys(doc);
Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?;
doc.dirty = true;
Self::sync_dirty(doc);
doc.generation += 1;
Ok(label)
}
/// Unsaved when the working graph is not the last opened or saved file. Undo back to that
/// file is clean; undo past a later save is not, because the restored snapshot's own base
/// is an earlier revision.
fn sync_dirty(doc: &mut Document) {
doc.dirty = doc.editor.as_ref().is_some_and(|e| e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved));
}
/// Use the current state's password for viewing (protection edits change it).
fn adopt_keys(doc: &mut Document) {
if let Some(e) = doc.editor.as_ref() {
@@ -3723,11 +3738,27 @@ impl Session {
pub fn save_bytes(&self, id: DocId) -> Result<Arc<Vec<u8>>, EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) };
if !editor.cos.is_modified() {
return Ok(editor.cos.bytes().clone());
let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved);
if !editor.cos.is_modified() && on_saved {
return Ok(editor.saved.clone());
}
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
guard(|| write_incremental(&editor.cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
// A restored snapshot can be based on an earlier revision. Append to the last saved
// file so that revision stays in the chain. A full rewrite (redaction, new encryption,
// a reconstructed file) still writes the current graph on its own.
let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty();
guard(|| {
if on_saved || rewrite {
write_incremental(&editor.cos, &opts)
} else {
let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?;
base.overlay_changes_from(&editor.cos)?;
write_incremental(&base, &opts)
}
})
.map_err(EditError::Write)?
.map(Arc::new)
.map_err(|e| EditError::Write(e.to_string()))
}
/// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork).
@@ -3758,8 +3789,11 @@ impl Session {
if let Some(editor) = doc.editor.as_mut() {
// An encrypted file needs the password it is protected with now (the owner
// password when known, so saving never downgrades this session's rights).
editor.cos = pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref())
.map_err(|e| EditError::Reopen(e.to_string()))?;
let keys = editor.keys.clone();
editor.cos =
pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
editor.saved = bytes;
editor.saved_keys = keys;
}
if let Some(p) = path {
doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone());
@@ -3808,10 +3842,10 @@ impl Session {
pub fn revert(&mut self, id: DocId) -> Result<(), EditError> {
let doc = self.doc_mut(id)?;
let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
let base = editor.cos.bytes().clone();
// The saved file opens with the passwords of the state that was saved: try the current
// ones, then each earlier state's.
let mut candidates = vec![editor.keys.clone()];
let base = editor.saved.clone();
// The saved file opens with the passwords it was saved with. The current snapshot may
// be an older revision (undo past that save), so its own base is not the saved file.
let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()];
candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone()));
candidates.push(Keys::default());
let (cos, keys) = candidates
+80 -1
View File
@@ -472,12 +472,85 @@ fn undo_still_works_after_save() {
s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap();
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved, None).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
s.undo(id).unwrap();
let doc = s.get(id).unwrap();
assert!(!doc.dirty, "undo back to the saved file is clean");
assert_eq!(doc.info.pages.len(), 1);
assert_eq!(doc.info.pages[0].rotation, 0);
s.undo(id).unwrap();
let doc = s.get(id).unwrap();
assert!(doc.dirty, "undoing past a save makes the document dirty again");
assert_eq!(doc.info.pages.len(), 2);
}
#[test]
fn undo_to_the_opened_file_is_not_dirty() {
let mut session = Session::new().with_clock(|| 1_700_000_000);
let opened = Arc::new(fixture(2));
let id = session.open("existing.pdf", None, opened.clone(), None).unwrap();
assert!(!session.get(id).unwrap().dirty);
assert!(session.autosave_snapshots().is_empty());
session.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap();
assert_eq!(session.get(id).unwrap().info.pages.len(), 1);
session.undo(id).unwrap();
let document = session.get(id).unwrap();
assert!(!document.dirty, "the opened bytes are the saved version");
assert_eq!(document.bytes, opened);
assert_eq!(document.info.pages.len(), 2);
assert!(session.autosave_snapshots().is_empty(), "nothing unsaved to recover");
session.redo(id).unwrap();
assert!(session.get(id).unwrap().dirty);
session.undo(id).unwrap();
assert!(!session.get(id).unwrap().dirty);
}
#[test]
fn save_after_undo_retains_the_latest_saved_revision() {
let (mut session, document) = session_with(3);
session.apply(document, Edit::DeletePages { pages: vec![1] }).unwrap();
let deleted = session.save_bytes(document).unwrap();
session.mark_saved(document, deleted, None).unwrap();
session.apply(document, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let rotated = session.save_bytes(document).unwrap();
session.mark_saved(document, rotated.clone(), None).unwrap();
let rotated_revisions = pdfcraft_cos::Document::open(rotated.clone()).unwrap().revisions().len();
session.undo(document).unwrap();
session.apply(document, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap();
let saved = session.save_bytes(document).unwrap();
assert!(saved.starts_with(rotated.as_slice()), "the saved rotation stays in the file");
let reopened = pdfcraft_cos::Document::open(saved.clone()).unwrap();
assert!(reopened.revisions().len() > rotated_revisions, "the title is a newer revision, not a rewrite of the earlier one");
assert_eq!(pdfcraft_organize::info(&reopened, "Title").as_deref(), Some("Report"));
session.mark_saved(document, saved, None).unwrap();
let doc = session.get(document).unwrap();
assert_eq!(page_texts(&session, document), ["Page 1", "Page 3"]);
assert_eq!(doc.info.pages[0].rotation, 0, "the undone rotation is the current page");
assert_eq!(doc.info_value("Title").as_deref(), Some("Report"));
}
#[test]
fn revert_preserves_the_saved_pages_after_undo() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let saved_bytes = s.save_bytes(id).unwrap();
s.mark_saved(id, saved_bytes, None).unwrap();
s.undo(id).unwrap();
assert!(s.get(id).unwrap().dirty);
assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0);
s.revert(id).unwrap();
let d = s.get(id).unwrap();
assert!(!d.dirty && d.can_undo().is_none() && d.can_redo().is_none());
assert_eq!(page_texts(&s, id), ["Page 2"], "the last saved pages, not the file as opened");
assert_eq!(d.info.pages[0].rotation, 90);
}
#[test]
fn full_save_is_readable_and_smaller_after_deletes() {
let (mut s, id) = session_with(5);
@@ -1113,8 +1186,14 @@ fn autosave_snapshots_only_changed_documents() {
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].bytes, s.get(id).unwrap().bytes, "the working file");
assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot");
s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap();
assert_eq!(s.autosave_snapshots().len(), 1);
s.undo(id).unwrap();
assert_eq!(s.autosave_snapshots().len(), 1, "undo is a change too");
assert!(s.get(id).unwrap().dirty, "the rotation is still unsaved");
assert_eq!(s.autosave_snapshots().len(), 1, "undo that leaves unsaved work is a change");
s.undo(id).unwrap();
assert!(!s.get(id).unwrap().dirty, "undo back to the opened file is saved");
assert!(s.autosave_snapshots().is_empty(), "a clean document needs no recovery");
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved, None).unwrap();
assert!(s.autosave_snapshots().is_empty(), "saved documents need no recovery");
-1
View File
@@ -296,7 +296,6 @@ fn the_open_recent_menu_lists_files_and_opens_one() {
h.get_by_label_contains("recent.pdf").click();
support::wait_until(&mut h, "recent document", |app| app.views.len() == 2 && app.password_prompt.is_none());
let app = h.state();
assert_eq!(app.views.len(), 2, "the recent file opened in a new tab");
let active = app.active.unwrap();
assert_eq!(app.session.get(app.views[active].id).and_then(|d| d.path.as_deref()), Some(path.as_str()), "the active tab is the recent file");
assert_eq!(app.session.get(app.views[active].id).unwrap().bytes.as_slice(), fixture(2).as_slice());
+3 -1
View File
@@ -1,6 +1,7 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -95,7 +96,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 |
| 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. 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). Missing: page transitions, recovery on the web |
| 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). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. |
| 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: 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. Missing: page transitions, recovery on the web |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
@@ -162,6 +163,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. |
| 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged |
| 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged |
| 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged |