Merge branch 'storytold:main' into feat/preferences-cards-refresh

This commit is contained in:
Harsh Verma authored and GitHub committed 2026-11-02 06:00:41 +00:00
commit fa818ccc08
17 files changed
+473 -16

No files matched your search

+3
View File
@@ -219,6 +219,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
- **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another.
- **2026-11-01 (M5/M14, customizable hotbar, freehand highlight, and moveable navigation rail):** Added Freehand-Highlight (`/Subtype /Ink`, `/IT /FreehandHighlight`, Multiply blending, 100 pt max line width) inside the standard Highlight flyout and headless `comment_add`; added a customizable floating or side-docked hotbar with a full-side chrome strip mode, settings gear icon anchored at the bar end, and 24–64 px sizing; and added Left/Right docking for the navigation rail (`rail_side`). Focused unit and integration tests pass across `annot`, `automation`, and `ui-egui`. Estimates unchanged.
@@ -743,6 +745,7 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and 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-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
+5 -2
View File
@@ -730,11 +730,14 @@ impl Matrix {
pub fn invert(&self) -> Option<Matrix> {
let [a, b, c, d, e, f] = self.0;
let det = a * d - b * c;
if det.abs() < 1e-12 || !det.is_finite() {
if det == 0.0 || !det.is_finite() || det.abs() <= 1e-12 * ((a * d).abs() + (b * c).abs()) {
return None;
}
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
Some(Matrix([ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)]))
let inverse = [ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)];
// A determinant that underflows toward zero can still pass the relative check above,
// and dividing by it overflows: such a matrix has no usable inverse.
inverse.iter().all(|v| v.is_finite()).then_some(Matrix(inverse))
}
/// The bounding box of a rectangle `[x0 y0 x1 y1]` after this transform.
+14
View File
@@ -552,3 +552,17 @@ fn inline_images_end_where_the_renderer_ends_them() {
assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data));
}
}
#[test]
fn invert_accepts_small_scale_and_rejects_singular() {
let m = Matrix([1e-7, 0., 0., 1e-7, 0., 0.]);
let inv = m.invert().expect("a uniform 1e-7 scale is invertible");
let id = m.then(&inv).0;
for (got, want) in id.iter().zip(Matrix::IDENTITY.0) {
assert!((got - want).abs() < 1e-9, "{id:?}");
}
assert!(Matrix([1., 2., 2., 4., 0., 0.]).invert().is_none());
assert!(Matrix([f64::NAN, 0., 0., 1., 0., 0.]).invert().is_none());
// A subnormal scale passes the relative check but its inverse overflows (CodeRabbit on #5703).
assert!(Matrix([1e-310, 0., 0., 1., 0., 0.]).invert().is_none());
}
+29 -3
View File
@@ -7,9 +7,25 @@ use super::place::{
};
use super::{Document, EditError, Object, added, backdrop, helvetica_width};
/// Commit one complete mark operation only after every selected page succeeds. The snapshot
/// shares original bytes, cross-references, caches and unchanged objects; only the overlay table
/// and document metadata are copied. Dropping a failed stage also drops removed marks, changed
/// page/resources/backdrop dictionaries and new objects, including the allocation cursor.
fn apply_mark_transaction(doc: &mut Document, apply: impl FnOnce(&mut Document) -> Result<(), EditError>) -> Result<(), EditError> {
let mut staged = doc.clone();
apply(&mut staged)?;
*doc = staged;
Ok(())
}
/// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on
/// those pages are removed first (Acrobat's Replace Existing).
/// those pages are removed first (Acrobat's Replace Existing). The whole call is atomic:
/// on error, the document (including earlier selected pages) is unchanged.
pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> {
apply_mark_transaction(doc, |staged| add_header_footer_in_place(staged, pages, hf, replace, cx))
}
fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> {
let all = page_list(doc);
check(pages, all.len())?;
if hf.text.iter().all(|t| t.trim().is_empty()) {
@@ -121,8 +137,13 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
Ok(())
}
/// Add a text watermark to `pages`.
/// Add a text or picture watermark to `pages`. All selected pages, including mark replacement,
/// form one transaction; on error, the document is unchanged.
pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> {
apply_mark_transaction(doc, |staged| add_watermark_in_place(staged, pages, wm, replace))
}
fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> {
let all = page_list(doc);
check(pages, all.len())?;
let text = wm.text.trim();
@@ -201,8 +222,13 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac
/// Add a solid-colour background behind the content of `pages`. With `bg.replace_backdrop`, a
/// white backdrop a page paints itself is replaced first, so the background shows (see
/// [`backdrop`]); a picture backdrop (a scan) still hides it.
/// [`backdrop`]); a picture backdrop (a scan) still hides it. All selected pages, including
/// replacement and backdrop edits, form one transaction; on error, the document is unchanged.
pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> {
apply_mark_transaction(doc, |staged| add_background_in_place(staged, pages, bg, replace))
}
fn add_background_in_place(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> {
let all = page_list(doc);
check(pages, all.len())?;
if replace {
+5
View File
@@ -1,6 +1,11 @@
//! pdfcraft-edit — page content editing (L4). Today: page marks, Acrobat's Header & footer,
//! Watermark, Background and Bates numbering (execution plan M7.6).
//!
//! **Atomic page-mark operations.** Adding or replacing headers/footers, watermarks and backgrounds
//! stages the whole call on a document snapshot and commits only after every selected page succeeds.
//! An error preserves existing marks, content, resources, pending edits and allocated objects. The
//! snapshot shares original PDF bytes and unchanged objects; it copies the overlay table and metadata.
//!
//! **How marks are stored.** Each mark is one content stream appended to (or, for content behind
//! the page, inserted after the backgrounds at the start of) the page's `/Contents`, drawn in
//! display space (after `/Rotate`) and wrapped in a marked-content artifact (§14.8.2.2) so
+211
View File
@@ -0,0 +1,211 @@
//! The public mark APIs either commit the complete operation or leave the document untouched.
use super::{cx, fixture, reopen, streams};
use crate::place::MARK_PLACEMENT_FAILURE;
use crate::*;
use pdfcraft_cos::{ObjRef, SaveOptions, write_incremental};
const SELECTED: &[usize] = &[0, 1, 2];
struct PlacementFailure;
impl PlacementFailure {
fn after_two_pages(kind: MarkKind) -> Self {
MARK_PLACEMENT_FAILURE.with(|failure| {
assert!(failure.get().is_none());
failure.set(Some((kind, 2, 0)));
});
Self
}
fn completed(&self) -> usize {
MARK_PLACEMENT_FAILURE.with(|failure| failure.get().unwrap().2)
}
}
impl Drop for PlacementFailure {
fn drop(&mut self) {
MARK_PLACEMENT_FAILURE.with(|failure| failure.set(None));
}
}
/// Four pages share an original stream and resources, both inherited and directly referenced.
/// All have all three existing marks, including a picture background with a replaced white fill.
fn marked_document() -> (Document, MarkSource) {
let mut doc = fixture();
let parent = ObjRef::new(2, 0);
let extra = doc.add(Object::Dict(Dict::from_iter([
(b"Type".to_vec(), Object::name("Page")),
(b"Parent".to_vec(), Object::Ref(parent)),
(b"Contents".to_vec(), Object::Ref(ObjRef::new(7, 0))),
])));
doc.update_dict(parent, |d| {
d.set(b"Kids".to_vec(), Object::Array([3, 4, 5].map(|n| Object::Ref(ObjRef::new(n, 0))).into_iter().chain([Object::Ref(extra)]).collect()));
d.set(b"Count".to_vec(), Object::Int(4));
})
.unwrap();
doc.set(ObjRef::new(7, 0), Object::Stream(Stream::from_raw(Dict::new(), b"1 g 0 0 600 800 re f 0 g BT /F1 9 Tf (Original page) Tj ET".to_vec())));
let picture = |doc: &mut Document, gray| {
let image = doc.add(Object::Stream(Stream::from_raw(
Dict::from_iter([
(b"Type".to_vec(), Object::name("XObject")),
(b"Subtype".to_vec(), Object::name("Image")),
(b"Width".to_vec(), Object::Int(1)),
(b"Height".to_vec(), Object::Int(1)),
(b"ColorSpace".to_vec(), Object::name("DeviceGray")),
(b"BitsPerComponent".to_vec(), Object::Int(8)),
]),
vec![gray],
)));
MarkSource { xobject: image, size: (1.0, 1.0), image: true }
};
let old_picture = picture(&mut doc, 170);
let replacement_picture = picture(&mut doc, 80);
let all = [0, 1, 2, 3];
let hf = HeaderFooter {
text: ["Old header".into(), String::new(), String::new(), String::new(), String::new(), String::new()],
font: added::Family::Times,
..Default::default()
};
add_header_footer(&mut doc, &all, &hf, false, &cx()).unwrap();
add_watermark(&mut doc, &all, &Watermark { text: "Old watermark".into(), opacity: 0.45, ..Default::default() }, false).unwrap();
add_background(&mut doc, &all, &Background { source: Some(old_picture), opacity: 0.6, replace_backdrop: true, ..Default::default() }, false)
.unwrap();
let pages = page_list(&doc);
let shared = ObjRef::new(6, 0);
let resources = (*doc.resolve(pages[0].dict.get(b"Resources").unwrap())).clone();
doc.set(shared, resources);
for (i, page) in pages.iter().enumerate() {
doc.update_dict(page.obj, |d| {
if i % 2 == 0 {
d.set(b"Resources".to_vec(), Object::Ref(shared));
} else {
d.remove(b"Resources");
}
})
.unwrap();
}
let doc = reopen(&doc);
assert!(!doc.is_modified());
let pages = page_list(&doc);
assert_eq!(pages.len(), 4);
assert!(doc.get(pages[1].obj).as_dict().unwrap().get(b"Resources").is_none(), "a selected page inherits its resources");
assert_eq!(pages[0].dict.get(b"Resources").and_then(Object::as_ref), Some(shared));
assert_eq!(pages[2].dict.get(b"Resources").and_then(Object::as_ref), Some(shared));
for i in 0..4 {
for kind in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] {
assert_eq!(mark_streams(&doc, i, kind).len(), 1, "page {i} has existing {kind:?}");
}
}
(doc, replacement_picture)
}
fn mark_streams(doc: &Document, page: usize, kind: MarkKind) -> Vec<String> {
streams(doc, page).into_iter().filter(|s| s.contains(&format!("/PCMark /{}", kind.tag()))).collect()
}
fn apply_replacement(doc: &mut Document, kind: MarkKind, source: MarkSource, replace: bool) -> Result<(), EditError> {
match kind {
MarkKind::HeaderFooter => add_header_footer(
doc,
SELECTED,
&HeaderFooter {
text: ["New header".into(), String::new(), String::new(), String::new(), String::new(), String::new()],
font: added::Family::Courier,
shrink: true,
..Default::default()
},
replace,
&cx(),
),
MarkKind::Watermark => add_watermark(doc, SELECTED, &Watermark { source: Some(source), opacity: 0.25, ..Default::default() }, replace),
MarkKind::Background => {
add_background(doc, SELECTED, &Background { color: [0.1, 0.3, 0.7], replace_backdrop: true, ..Default::default() }, replace)
}
}
}
fn unchanged_after_later_page_failure(kind: MarkKind) {
// Replacement and addition share the same operation boundary; exercise replacement first.
for (replace, pending_edit) in [(true, false), (true, true), (false, false), (false, true)] {
let (mut doc, source) = marked_document();
if pending_edit {
let pending = doc.add(Object::String(pdfcraft_cos::PdfString::literal(b"unsaved caller edit".to_vec())));
doc.update_dict(page_list(&doc)[0].obj, |d| d.set(b"CallerEdit".to_vec(), Object::Ref(pending))).unwrap();
}
let modified = doc.is_modified();
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
let objects = doc.object_numbers().len();
let mut allocation_probe = doc.clone();
let next_object = allocation_probe.add(Object::Null);
let failure = PlacementFailure::after_two_pages(kind);
let result = apply_replacement(&mut doc, kind, source, replace);
assert_eq!(result, Err(EditError::Invalid("injected mark placement failure".into())));
assert_eq!(failure.completed(), 2, "the error followed two real page placements");
assert_eq!(doc.object_numbers().len(), objects, "{kind:?}, replace={replace}: no allocated objects remain");
assert_eq!(doc.is_modified(), modified, "{kind:?}, replace={replace}: the dirty state is preserved");
assert!(write_incremental(&doc, &SaveOptions::default()).unwrap() == before, "{kind:?}, replace={replace}: every byte stays unchanged");
assert_eq!(doc.add(Object::Null), next_object, "the allocation cursor is unchanged too");
}
}
#[test]
fn header_footer_changes_are_atomic_after_a_later_page_failure() {
unchanged_after_later_page_failure(MarkKind::HeaderFooter);
}
#[test]
fn watermark_changes_are_atomic_after_a_later_page_failure() {
unchanged_after_later_page_failure(MarkKind::Watermark);
}
#[test]
fn background_changes_are_atomic_after_a_later_page_failure() {
unchanged_after_later_page_failure(MarkKind::Background);
}
#[test]
fn replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources() {
for kind in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] {
let (mut doc, source) = marked_document();
let before = doc.clone();
let expected = match kind {
MarkKind::HeaderFooter => "(New header)".to_string(),
MarkKind::Watermark => format!("/PCPic{} Do", source.xobject.num),
MarkKind::Background => "0.1 0.3 0.7 rg".to_string(),
};
apply_replacement(&mut doc, kind, source, true).unwrap();
let doc = reopen(&doc);
assert_eq!(streams(&doc, 3), streams(&before, 3), "the unselected page keeps its content");
let unselected = page_list(&before)[3].obj;
assert_eq!(doc.get(unselected), before.get(unselected), "the unselected page keeps its resources and entries");
for reference in [ObjRef::new(6, 0), ObjRef::new(7, 0)] {
assert_eq!(
format!("{:?}", doc.get(reference)),
format!("{:?}", before.get(reference)),
"shared resources and original content stay unchanged"
);
}
for page in SELECTED {
let marks = mark_streams(&doc, *page, kind);
assert_eq!(marks.len(), 1, "one replacement on selected page {page}");
assert!(marks[0].contains(&expected), "the new mark is present: {}", marks[0]);
let old_page = &page_list(&before)[*page];
let new_page = &page_list(&doc)[*page];
let old_resources = before.resolve(old_page.dict.get(b"Resources").unwrap());
let new_resources = doc.resolve(new_page.dict.get(b"Resources").unwrap());
for category in [&b"Font"[..], &b"ExtGState"[..], &b"XObject"[..]] {
let old = before.resolve(old_resources.as_dict().unwrap().get(category).unwrap());
let new = doc.resolve(new_resources.as_dict().unwrap().get(category).unwrap());
for (name, value) in old.as_dict().unwrap().iter() {
assert_eq!(new.as_dict().unwrap().get(name), Some(value), "old resources remain usable on page {page}");
assert_eq!(doc.resolve(value), before.resolve(value), "referenced resource contents are unchanged on page {page}");
}
}
for other in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] {
if other != kind {
assert_eq!(mark_streams(&doc, *page, other), mark_streams(&before, *page, other), "unrelated {other:?} on page {page}");
}
}
}
}
}
+26 -1
View File
@@ -294,7 +294,32 @@ pub(crate) fn isolation(doc: &Document, list: &[Object]) -> Isolation {
/// Put a mark's content on a page: behind (with the behind-marks at the start) or on top
/// (appended, after wrapping the original content in q/Q).
pub(crate) fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
place_tagged(doc, page, kind.tag(), content, behind).map(drop)
place_tagged(doc, page, kind.tag(), content, behind)?;
#[cfg(test)]
fail_after_mark_placement(kind)?;
Ok(())
}
#[cfg(test)]
thread_local! {
/// Force an error after N completed placements of one kind, on this test thread only.
pub(crate) static MARK_PLACEMENT_FAILURE: std::cell::Cell<Option<(MarkKind, usize, usize)>> = const { std::cell::Cell::new(None) };
}
#[cfg(test)]
fn fail_after_mark_placement(kind: MarkKind) -> Result<(), EditError> {
MARK_PLACEMENT_FAILURE.with(|failure| {
if let Some((target, after, completed)) = failure.get()
&& target == kind
{
let completed = completed.saturating_add(1);
failure.set(Some((target, after, completed)));
if completed == after {
return Err(EditError::Invalid("injected mark placement failure".into()));
}
}
Ok(())
})
}
/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the
+3
View File
@@ -4,6 +4,9 @@ use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental};
use super::*;
#[path = "mark_transaction_tests.rs"]
mod mark_transactions;
/// Three pages: an upright page with content that leaves the graphics state changed (an
/// unbalanced `cm`), a page rotated 90° and one with inherited, shared resources.
fn fixture() -> Document {
+2
View File
@@ -144,6 +144,7 @@ impl PdfCraftApp {
#[cfg(all(test, not(target_arch = "wasm32")))]
open_prepare_gate: None,
pinned: Default::default(),
recent_check: Default::default(),
pinned_tools: Vec::new(),
pin_tool_search: String::new(),
toast: None,
@@ -452,6 +453,7 @@ impl eframe::App for PdfCraftApp {
}
self.poll_pending_opens();
}
self.check_restored_recent();
// Chinese wants its own faces before the Japanese ones (one baseline per line).
let cjk = theme::CjkPreference::for_language(lang.code());
if !self.styled {
+1
View File
@@ -332,6 +332,7 @@ impl PdfCraftApp {
#[cfg(target_os = "macos")]
macos_association::repair(std::path::Path::new(&p));
self.recent.retain(|r| r.path != p);
self.recent_check.opened(&p);
self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size });
self.recent.truncate(self.recent_limit);
}
+3 -3
View File
@@ -182,9 +182,9 @@ impl PdfCraftApp {
self.organize_zoom = zoom.clamp(*canvas::GRID_ZOOM_RANGE.start(), *canvas::GRID_ZOOM_RANGE.end());
}
if let Ok(r) = serde_json::from_value::<Vec<RecentFile>>(v["recent"].clone()) {
// Only keep entries whose files still exist.
#[cfg(not(target_arch = "wasm32"))]
let r: Vec<RecentFile> = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect();
// Entries whose files are gone leave once the window is up: asking here could block
// startup on a mount that has stopped answering (#5682).
self.recent_check.restored(r.iter().map(|f| f.path.clone()).collect());
self.recent = r;
}
// Nonsense keeps the limit it had; settings from before the limit have none.
+2
View File
@@ -392,6 +392,8 @@ pub struct PdfCraftApp {
open_prepare_gate: Option<std::sync::Arc<open_worker::PrepareGate>>,
/// Folders pinned to Home, and what they held when last listed.
pub pinned: folders_ui::PinnedFolders,
/// The recent files restored from settings, checked off the UI thread (#5682).
recent_check: persist::recent_check::RecentCheck,
/// Tools pinned to Home, in user-defined order.
pub pinned_tools: Vec<String>,
/// Search query for the Pin a tool selection dialog.
+1
View File
@@ -1,6 +1,7 @@
pub mod last_session;
pub mod last_view;
pub mod paper;
pub(crate) mod recent_check;
pub(super) mod recovery;
pub mod units;
#[cfg(not(target_arch = "wasm32"))]
+125
View File
@@ -0,0 +1,125 @@
//! Recent files whose files are gone leave the list, without the window waiting to find out
//! (#5682).
//!
//! A recent file can be on a network or cloud mount that has stopped answering, where even
//! asking whether the file exists blocks in the kernel, and no timeout can cancel that. So
//! settings keep every recent entry when they load, and a worker asks about them once the window
//! is up; each frame only takes the answers it has so far. An entry the worker hasn't reached
//! yet, or can't tell about (the mount answers with an error), stays listed and can be opened or
//! removed by hand. A worker stuck on a dead mount stays stuck, but nothing waits for it.
use std::sync::{Arc, Mutex, PoisonError};
use crate::PdfCraftApp;
/// The recent files restored from settings that haven't been checked yet, and the check under way.
#[derive(Debug, Default)]
pub struct RecentCheck {
/// Paths restored from settings, checked on the next frame.
unchecked: Vec<String>,
/// The paths the worker has found gone so far; it adds to them as it goes.
gone: Option<Arc<Mutex<Vec<String>>>>,
/// Paths opened while a check was pending: the file is there now, whatever the worker found.
opened: Vec<String>,
}
impl RecentCheck {
/// Settings just restored these recent files: check them once the window is up.
pub(crate) fn restored(&mut self, paths: Vec<String>) {
self.unchecked = paths;
}
/// `path` was just opened: an answer the worker gives about it later is out of date.
pub(crate) fn opened(&mut self, path: &str) {
if self.gone.is_some() || !self.unchecked.is_empty() {
self.opened.push(path.to_string());
}
}
}
/// Whether `path` is known to be gone. Any other answer, an error included, keeps the entry.
fn gone(path: &str) -> bool {
matches!(std::fs::exists(path), Ok(false))
}
impl PdfCraftApp {
/// Start checking the recent files restored from settings, and drop the ones found gone so
/// far (called each frame). The check runs on a worker thread, inline in tests
/// ([`PdfCraftApp::run_inline`]).
pub(crate) fn check_restored_recent(&mut self) {
let paths = std::mem::take(&mut self.recent_check.unchecked);
// The web build's recent files aren't paths on a file system.
if !paths.is_empty() && !cfg!(target_arch = "wasm32") {
let slot = Arc::new(Mutex::new(Vec::new()));
let out = slot.clone();
let ctx = self.ctx.clone();
let work = move || {
for path in paths {
if gone(&path) {
out.lock().unwrap_or_else(PoisonError::into_inner).push(path);
if let Some(ctx) = &ctx {
ctx.request_repaint();
}
}
}
};
if self.run_inline {
work();
} else if std::thread::Builder::new().name("pdfcraft-recent".into()).spawn(work).is_err() {
// No worker: the entries stay listed, as if the check hadn't reached them.
return;
}
self.recent_check.gone = Some(slot);
}
let Some(slot) = &self.recent_check.gone else { return };
// Asked before taking, so a path the worker adds after this is taken on a later frame.
let finished = Arc::strong_count(slot) == 1;
let gone = std::mem::take(&mut *slot.lock().unwrap_or_else(PoisonError::into_inner));
if !gone.is_empty() {
// A file opened since the check began is there, whatever the worker found earlier.
let opened = &self.recent_check.opened;
self.recent.retain(|r| !gone.contains(&r.path) || opened.contains(&r.path));
}
// The worker has finished (or panicked) and everything it found is taken.
if finished {
self.recent_check.gone = None;
self.recent_check.opened.clear();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A file opened after the worker found it gone, but before that answer was taken, stays
/// listed (CodeRabbit on #5692).
#[test]
fn a_file_opened_while_the_check_is_pending_stays() {
let mut app = PdfCraftApp::new();
app.recent = vec![crate::state::RecentFile { name: "a.pdf".into(), path: "/gone/a.pdf".into(), pages: 1, size: 0 }];
// The worker's answer is waiting: it found the file gone.
app.recent_check.gone = Some(Arc::new(Mutex::new(vec!["/gone/a.pdf".to_string()])));
app.recent_check.opened("/gone/a.pdf");
app.check_restored_recent();
assert_eq!(app.recent.len(), 1, "the reopened file stays");
assert!(app.recent_check.gone.is_none() && app.recent_check.opened.is_empty());
// Without the open, the answer removes it.
app.recent_check.gone = Some(Arc::new(Mutex::new(vec!["/gone/a.pdf".to_string()])));
app.check_restored_recent();
assert!(app.recent.is_empty());
}
#[test]
fn only_a_path_known_to_be_missing_is_gone() {
let dir = std::env::temp_dir().join(format!("pdfcraft-recent-check-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let here = dir.join("here.pdf");
std::fs::write(&here, b"%PDF-1.7").unwrap();
assert!(!gone(&here.to_string_lossy()));
assert!(gone(&dir.join("missing.pdf").to_string_lossy()));
// A path that can't be asked about (here: one with a NUL) is not known to be gone.
assert!(!gone("bad\0path.pdf"));
std::fs::remove_dir_all(&dir).unwrap();
}
}
+33
View File
@@ -221,6 +221,39 @@ fn the_limit_is_saved_and_restored_and_cuts_a_longer_list() {
std::fs::remove_dir_all(&dir).unwrap();
}
/// #5682: restoring settings never asks the file system about recent files, which can block on a
/// mount that has stopped answering. A gone file leaves the list once frames run, and a file that
/// is there stays.
#[test]
fn restoring_settings_does_not_check_recent_files_and_a_frame_drops_the_gone_ones() {
let dir = std::env::temp_dir().join(format!("pdfcraft-recent-restore-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let here = dir.join("here.pdf");
std::fs::write(&here, PDF).unwrap();
let here = here.to_string_lossy().into_owned();
let gone = dir.join("gone.pdf").to_string_lossy().into_owned();
let entries: Vec<RecentFile> =
[&here, &gone].iter().map(|p| RecentFile { name: "x.pdf".into(), path: (*p).clone(), pages: 1, size: 0 }).collect();
let json = serde_json::json!({ "recent": entries }).to_string();
let mut restored = PdfCraftApp::new();
restored.restore(&json);
let paths: Vec<&str> = restored.recent.iter().map(|r| r.path.as_str()).collect();
assert_eq!(paths, [here.as_str(), gone.as_str()], "restoring keeps every entry without asking whether its file exists");
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1600.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.run_inline = true;
app.restore(&json);
app
});
h.run_steps(2);
let paths: Vec<&str> = h.state().recent.iter().map(|r| r.path.as_str()).collect();
assert_eq!(paths, [here.as_str()], "the gone file leaves once the window is up");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_settings_file_can_not_ask_for_a_limit_that_is_not_one() {
for (saved, want) in [
+4 -1
View File
@@ -1,5 +1,7 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged)
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged)
@@ -223,7 +225,7 @@ total matches the parity estimate.
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
@@ -296,6 +298,7 @@ Estimates unchanged.
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
| 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. |
| 2026-10-31 | patch | M3 #1354: reconcile the launch override with split app/file modules and the structured open result; preserve headless help/version exits. CI validation pending. |
| 2026-10-31 | patch | M2, PR #4172: eight GBK font/alias cases compare exact pixels with independent installed-face and explicit-Unicode references; wrong-glyph and wrong-family negative controls fail. Estimates unchanged. |
+6 -6
View File
@@ -2460,8 +2460,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.header_footer.update", "edit.header_footer.remove"]
tools = ["doc_header_footer", "doc_remove_marks"]
evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"]
notes = "recognises PdfCraft marks; Acrobat-made headers are not detected yet"
evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/mark_transaction_tests.rs::header_footer_changes_are_atomic_after_a_later_page_failure", "crates/edit/src/mark_transaction_tests.rs::replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources"]
notes = "Add and Update are atomic across all selected pages: errors preserve existing marks, page content/resources and pending edits (#5657). recognises PdfCraft marks; Acrobat-made headers are not detected yet"
[[feature]]
id = "edit.header-footer-tokens"
@@ -2532,8 +2532,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.watermark.update", "edit.watermark.remove"]
tools = ["doc_watermark", "doc_remove_marks"]
evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
notes = ""
evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/edit/src/mark_transaction_tests.rs::watermark_changes_are_atomic_after_a_later_page_failure", "crates/edit/src/mark_transaction_tests.rs::replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources"]
notes = "Add and Update are atomic across all selected pages: errors preserve existing marks, page content/resources and pending edits (#5657). "
[[feature]]
id = "edit.watermark-appearance"
@@ -2568,8 +2568,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.background.update", "edit.background.remove"]
tools = ["doc_background", "doc_remove_marks"]
evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/edit/src/backdrop_tests.rs::a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes", "crates/edit/src/backdrop_tests.rs::an_edit_after_the_fill_is_kept_and_the_backdrop_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_copy_written_out_again_with_other_spacing_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_change_before_the_fill_leaves_it_off_and_names_the_page", "crates/edit/src/backdrop_tests.rs::a_fill_that_would_cover_content_painted_before_it_stays_off", "crates/engine/src/tests.rs::remove_background_names_the_pages_whose_own_backdrop_stays_off", "crates/automation/tests/it/backdrop.rs::removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop"]
notes = "Remove also turns back on a page backdrop Add replaced, keeping edits made after it; where that isn't safe it says which pages keep it off"
evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/edit/src/backdrop_tests.rs::a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes", "crates/edit/src/backdrop_tests.rs::an_edit_after_the_fill_is_kept_and_the_backdrop_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_copy_written_out_again_with_other_spacing_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_change_before_the_fill_leaves_it_off_and_names_the_page", "crates/edit/src/backdrop_tests.rs::a_fill_that_would_cover_content_painted_before_it_stays_off", "crates/engine/src/tests.rs::remove_background_names_the_pages_whose_own_backdrop_stays_off", "crates/automation/tests/it/backdrop.rs::removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop", "crates/edit/src/mark_transaction_tests.rs::background_changes_are_atomic_after_a_later_page_failure", "crates/edit/src/mark_transaction_tests.rs::replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources"]
notes = "Add and Update are atomic across all selected pages: errors preserve existing marks, page content/resources and pending edits (#5657). Remove also turns back on a page backdrop Add replaced, keeping edits made after it; where that isn't safe it says which pages keep it off"
[[feature]]
id = "edit.decoration-profiles"