M14: edits let go of the previous working file before writing the next (gap 13) (#1279)

* M0: keep the camera edge test NaN-safe under clippy's manual_range_contains

Clippy 1.97 flags `d < 0.35 || d > PI - 0.35` in the camera edge hit test. Its suggested
`!(0.35..=PI - 0.35).contains(&d)` would count a NaN direction as a hit, which the two
comparisons did not, so the NaN check is kept explicitly. Behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011AsuqvnFXhY9ASL1bA2jtm

* M14: edits let go of the previous working file before writing the next (gap 13)

Every edit writes a new working file: the original plus one appended revision. The previous
one stayed alive in the working bytes, the display bytes and the renderer while the next was
written, so after a few edits a 1 GiB file peaked at three copies.

Each refresh path (full, comments/form, content, undo, redo, after save) now releases them
first: the working and display bytes fall back to the original (already held by the editor)
and the renderer is replaced by an empty one until the refresh rebuilds it. A refresh that
still fails after rollback draws the original instead of nothing. Save still writes from the
editor, never from these.

Measured on a synthetic 1 GiB file (release build, Linux): five edits peak at 2.1 GiB instead
of 3.1 GiB, in 8.5 s instead of 16 s. Saving still peaks at three copies; reading from disk on
demand stays blocked on the renderer (hayro parses one contiguous buffer, gap 2). gaps.md,
the parity notes and the roadmap record both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011AsuqvnFXhY9ASL1bA2jtm

---------

Co-authored-by: Claude <[email protected]>
This commit is contained in:
Raj PatelandClaude Opus 5.5 authored and GitHub committed 2026-10-12 01:28:01 -04:00
1 parent c52072b678
commit 1970935587
7 files changed
+123 -11

No files matched your search

+1
View File
@@ -137,6 +137,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-12 (M14, gap 13, large-file memory):** Measured on synthetic 1 GiB and 100,000-page files. Edits let go of the previous working file (working bytes, display bytes, renderer) before writing the next, so a 1 GiB file peaks at two copies while editing instead of three (five edits: 3.1 → 2.1 GiB, 16 → 8.5 s). Saving still peaks at three; reading from disk on demand is blocked on the renderer (gap 2). Estimates unchanged.
- **2026-10-12 (M2, short text redraws):** Same-line redraws that restart a matching three-glyph prefix after more than one glyph-height of progress split before placement, so short doubled words no longer interleave. The existing redraw-position tolerance is reused; controls cover negative TJ movement, combining marks, RTL, CJK, vertical and turned text. Estimates unchanged.
- **2026-10-11 (M10.5, focused follow-up to #599):** Carry the architectural/ANSI/large ISO paper presets, current-page matching at 100%, and previewed PDF scale into the current print dialog and `doc_print`. Retains the #847/#1007 spooler and background discovery. Driver substitution, physical calibration, vector Windows printing and managed-endpoint restrictions remain separate gaps. Overall estimates unchanged.
+2 -1
View File
@@ -232,7 +232,8 @@ fn support(a: Point, b: Point, edges: &[Option<Edge>], w: usize, h: usize) -> f6
edges.get(y as usize * w + x as usize).copied().flatten().is_some_and(|e| {
// Either way across: dark-to-light or light-to-dark.
let d = (e.dir - normal).rem_euclid(std::f32::consts::PI);
d < 0.35 || d > std::f32::consts::PI - 0.35
// A NaN direction is no hit, as the two comparisons it replaces said.
!d.is_nan() && !(0.35..=std::f32::consts::PI - 0.35).contains(&d)
})
});
hits += hit as usize;
+35 -5
View File
@@ -3208,7 +3208,9 @@ impl Session {
ed.keys = keys;
}
Self::adopt_keys(doc);
let _ = guard(|| Self::refresh(doc));
if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) {
Self::show_released(doc);
}
return Err(e);
}
doc.dirty = true;
@@ -3237,7 +3239,7 @@ impl Session {
let current_keys = std::mem::replace(&mut editor.keys, keys);
editor.redo.push((label.clone(), current, current_keys, scope));
Self::adopt_keys(doc);
Self::refresh_scoped(doc, scope)?;
Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?;
doc.dirty = true;
doc.generation += 1;
Ok(label)
@@ -3252,7 +3254,7 @@ impl Session {
let current_keys = std::mem::replace(&mut editor.keys, keys);
editor.undo.push((label.clone(), current, current_keys, scope));
Self::adopt_keys(doc);
Self::refresh_scoped(doc, scope)?;
Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?;
doc.dirty = true;
doc.generation += 1;
Ok(label)
@@ -3277,6 +3279,10 @@ impl Session {
if scope == Scope::Content {
return Self::refresh_content(doc, None);
}
// Metadata edits keep the renderer, which holds the working file anyway.
if scope != Scope::Metadata {
Self::release_working(doc);
}
let Some(editor) = doc.editor.as_mut() else { return Ok(()) };
// Comment edits, and form edits (a script may change annotation properties), may change
// annotation dictionaries, invalidating cached appearances.
@@ -3369,6 +3375,7 @@ impl Session {
/// from undo/redo, re-reads both: always correct, rarely paid). The cached display
/// appearances are kept: content edits leave every annotation dictionary alone.
fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> {
Self::release_working(doc);
let Some(editor) = doc.editor.as_mut() else { return Ok(()) };
// A script may have laid an XFA form out again (rows added, subforms shown).
if doc.xfa.is_some()
@@ -3401,6 +3408,28 @@ impl Session {
Ok(())
}
/// Let go of the working file before the next one is written (gap 13). The working file is
/// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the
/// working bytes, the display bytes and the renderer) while writing the new one held three
/// copies at once; this keeps two. Until the refresh finishes, the working and display bytes
/// are the original's (already held by the editor, so free) and the renderer draws nothing.
/// A failed edit rolls back and refreshes again, which replaces all three; where a refresh
/// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes
/// from the editor, never from these.
fn release_working(doc: &mut Document) {
let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return };
// Dropping the pool stops its workers; each lets go of the file when it exits.
doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone());
doc.display = original.clone();
doc.bytes = original;
}
/// After a refresh failed: draw the display bytes it left (the original, once released),
/// so the page still shows a document.
fn show_released(doc: &mut Document) {
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
}
/// Rebuild working bytes, inspection and renderer from the current edit state.
fn refresh(doc: &mut Document) -> Result<(), EditError> {
// A full refresh may follow structural or annotation changes: cached appearances
@@ -3408,6 +3437,7 @@ impl Session {
if let Some(editor) = doc.editor.as_mut() {
editor.appearances.clear();
}
Self::release_working(doc);
let Some(editor) = doc.editor.as_mut() else { return Ok(()) };
// A script may have laid an XFA form out again (rows added, subforms shown).
if doc.xfa.is_some()
@@ -3484,7 +3514,7 @@ impl Session {
}
doc.dirty = false;
doc.generation += 1;
Self::refresh(doc)
Self::refresh(doc).inspect_err(|_| Self::show_released(doc))
}
/// Enter the permissions (owner) password of a document opened with its user password,
@@ -3542,7 +3572,7 @@ impl Session {
Self::adopt_keys(doc);
doc.dirty = false;
doc.generation += 1;
Self::refresh(doc)
Self::refresh(doc).inspect_err(|_| Self::show_released(doc))
}
/// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open
+69
View File
@@ -3881,3 +3881,72 @@ fn fonts_added_by_a_content_edit_show_after_refresh_fonts() {
session.refresh_fonts(id);
assert_eq!(format!("{:?}", session.get(id).unwrap().info.fonts), fonts);
}
/// Gap 13: every edit writes a new working file (the original plus one revision). Before the
/// next one is written, the current one is let go of by the working bytes, the display bytes and
/// the renderer: on a 1 GiB file, keeping it is a whole extra gigabyte at the peak of each edit.
#[test]
fn the_working_file_is_let_go_of_before_the_next_is_written() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
let doc = s.doc_mut(id).unwrap();
let (working, original) = (Arc::downgrade(&doc.bytes), doc.editor.as_ref().unwrap().cos.bytes().clone());
assert!(!Arc::ptr_eq(&doc.bytes, &original), "an edited working file is a buffer of its own");
Session::release_working(doc);
assert!(Arc::ptr_eq(&doc.bytes, &original) && Arc::ptr_eq(&doc.display, &original));
// The old renderer's workers let go of the file when they exit, just after.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while working.upgrade().is_some() {
assert!(std::time::Instant::now() < deadline, "the working file is still held");
std::thread::sleep(std::time::Duration::from_millis(5));
}
// The refresh that follows brings everything back.
Session::refresh(doc).unwrap();
assert_eq!(s.get(id).unwrap().info.pages[1].rotation, 90);
let _ = shown(&s, id, 1);
}
/// Every refresh path (full, comments, content, undo, redo) lets go of the previous working file
/// and still shows the current state.
#[test]
fn edits_let_go_of_the_previous_working_file() {
let (mut s, id) = session_with(2);
let shape = Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] };
let comment = || {
Edit::AddAnnotation(NewAnnotation {
page: 0,
style: Style::default_for(&shape),
shape: shape.clone(),
contents: String::new(),
author: "Test".into(),
})
};
let watermark =
|| Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "Draft".into(), ..Watermark::default() }, file: None, replace: false };
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
// A full refresh, a comment refresh, a content refresh, then undo and redo.
let steps: [(&str, Box<dyn Fn(&mut Session)>); 5] = [
("rotate", Box::new(|s: &mut Session| s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap())),
("comment", Box::new(move |s: &mut Session| s.apply(id, comment()).unwrap())),
("watermark", Box::new(move |s: &mut Session| s.apply(id, watermark()).unwrap())),
("undo", Box::new(|s: &mut Session| drop(s.undo(id).unwrap()))),
("redo", Box::new(|s: &mut Session| drop(s.redo(id).unwrap()))),
];
for (name, step) in steps {
let (bytes, display) = {
let doc = s.get(id).unwrap();
(Arc::downgrade(&doc.bytes), Arc::downgrade(&doc.display))
};
step(&mut s);
// The old renderer's workers let go of the file when they exit, just after.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while bytes.upgrade().is_some() || display.upgrade().is_some() {
assert!(std::time::Instant::now() < deadline, "{name}: the previous working file is still held");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
// What is shown is still the current state.
let doc = s.get(id).unwrap();
assert_eq!(doc.info.annotations.len(), 1);
let _ = shown(&s, id, 0);
}
+13 -2
View File
@@ -1,6 +1,6 @@
# Where PdfCraft falls short of Acrobat Pro
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Word invoice regression coverage, #773; Office fidelity still partial; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 13: large-file memory measured, edits keep two copies instead of three, file-backed input blocked on gap 2) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (gap 1 closed by #836; fixed issues removed from gaps 3, 6 and 16) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (document-font subset completion and curved/shared image clips in the contributor fork; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -218,7 +218,17 @@ update the numbers.
memory budget, with budgets checked on Tier-1 platforms.
- **Evidence:** #307 brought a 9,156-page file from > 10 GiB to ≈ 500 MiB inspection, but
file-backed input and global memory budgets remain open; blurry fonts at common resolutions
(#730).
(#730). Measured 2026-10-12 on synthetic files (release build, Linux): a 1 GiB file opens at
1.0 GiB peak in 0.7 s, so viewing holds the whole file once; after edits it held two copies and
peaked at three (3.1 GiB after five edits), because the previous working file stayed alive while
the next was written. Edits now let go of it first: five edits peak at 2.1 GiB (8.5 s, was 16 s).
Saving still peaks at three copies (the saved file is assembled in memory beside the working
one), and display appearances for comments without one add a copy. A 100,000-page file (28 MB)
opens at 403 MiB in 5.5 s and each edit re-inspects it for ≈ 5 s.
- **Blocked part:** reading a GB file from disk on demand (`ByteSource`) needs every reader to
take ranges, but the `hayro` renderer parses one contiguous buffer (`Pdf::new(PdfData)`), so it
waits on gap 2's renderer decision. Until then the budget is one copy of the file to view and two
to edit.
- **Estimate:** 25–45 h. **Doc:** this file.
### 14. Arabic not mirrored; no Hindi, Indonesian, Korean, Vietnamese
@@ -309,6 +319,7 @@ update the numbers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Gap 13: measured large-file memory; edits no longer keep the previous working file while writing the next (peak 3 → 2 copies); file-backed input recorded as blocked on gap 2 |
| 2026-10-11 | minor | Word invoice failure #773 has synthetic regression coverage; broader Office conversion remains gap 8. |
| 2026-10-11 | minor | Contributor fork: mixed-style visual regions, source-font space reuse and a read-only textless background. Embedded sfnt source faces and Mac subset Unicode mappings keep edit baselines stable; live frames follow Apply/Save geometry as text grows or shrinks. Engine/tool/UI regressions and private all-page checks cover the changes; full matching font import, CFF, range styles, shaping and private Chinese typing checks added; legacy programs and general fidelity remain open; estimates unchanged |
| 2026-10-11 | patch | Gap 2: 23 hayro-interpret and 10 hayro patches after axial and radial shadings became native gradients (backport of hayro 0.8) |
+2 -2
View File
@@ -94,9 +94,9 @@ total matches the parity estimate.
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools).<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) |
## Upcoming milestones, ranked
+1 -1
View File
@@ -395,7 +395,7 @@ status = "planned"
commands = []
tools = []
evidence = []
notes = "ByteSource (M1.5); files are buffered whole today"
notes = "ByteSource (M1.5); files are buffered whole today. Viewing holds the file once and editing twice: edits let go of the previous working file before writing the next (a 1 GiB file peaked at three copies after a few edits, now two). Saving still peaks at three. Reading from disk on demand waits on the renderer (hayro parses one contiguous buffer, gap 2)."
[[feature]]
id = "core.pdf20-utf8-strings"