From 4f0493cc21e512087f6e625a5ddbd5426b777d21 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:09:53 +0300 Subject: [PATCH 01/59] organize: split a combined field/widget so every copied page owns its widget --- crates/organize/src/import.rs | 90 ++++++++++++++++++++++++++++------- crates/organize/src/tests.rs | 64 ++++++++++++++++++++----- 2 files changed, 125 insertions(+), 29 deletions(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 588539866..ef18a121f 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -34,6 +34,13 @@ const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; /// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). const MAX_COPY_DEPTH: usize = 256; +/// Keys that stay on the widget when a combined field/widget is split (#1810): the annotation's +/// appearance, placement and page. Field state (name, value, flags, default appearance) moves to +/// the new parent field, where the widgets inherit it. +const WIDGET_KEYS: &[&[u8]] = &[ + b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"BS", b"Border", b"C", b"CA", b"F", b"H", b"R", b"NM", b"M", b"StructParent", b"StructParents", b"OC", b"Tabs", +]; + struct Copier<'a> { src: &'a Document, /// Current recursion depth, bounded by [`MAX_COPY_DEPTH`]. @@ -393,27 +400,32 @@ fn import_pages_mapped( let r = dst.add(Object::Null); let c = copier.copy_value(dst, &resolved, Some(r))?; dst.set(r, c); - // `/Kids` may be an array object of its own: add to it there, so the - // field keeps its other widgets. - match dst.get(copied).as_dict().and_then(|f| f.get(b"Kids").cloned()) { - Some(Object::Ref(list)) => { - let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); - kids.push(Object::Ref(r)); - dst.set(list, Object::Array(kids)); - } - _ => dst.update_dict(copied, |f| { - let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); - kids.push(Object::Ref(r)); - f.set(b"Kids".to_vec(), Object::Array(kids)); - })?, - } + add_kid(dst, copied, r)?; annot_pages.insert(r, new); out.push(Object::Ref(r)); continue; } - // A widget that is its own field (no parent to hang a second widget on) stays - // the one object, shown on both pages, so the field stays one. + // A widget that is its own field (no parent to hang a second widget on): on + // its first copy it is split into a parent field and a widget child (#1810); + // every later copy of the page gets one more widget child of that field, so + // the pages no longer share one widget whose `/P` names only the last page. let own_field = resolved.as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent")); + if own_field + && let Some(first) = a.as_ref().and_then(|r| copier.map.get(&r).copied()) + && let Some(parent) = dst.get(first).as_dict().and_then(|d| d.reference(b"Parent")) + { + let r = dst.add(Object::Null); + let c = copier.copy_value(dst, &resolved, Some(r))?; + let Object::Dict(mut d) = c else { continue }; + d.set(b"Parent".to_vec(), Object::Ref(parent)); + // The new widget is not a field root of its own. + copier.fields.retain(|f| *f != r); + dst.set(r, Object::Dict(d)); + add_kid(dst, parent, r)?; + annot_pages.insert(r, new); + out.push(Object::Ref(r)); + continue; + } let annots = if own_field { &mut copier } else { own.as_mut().unwrap_or(&mut copier) }; // Direct (inline) annotation dictionaries become indirect objects, so every // copied annotation can be fixed up the same way. @@ -427,6 +439,9 @@ fn import_pages_mapped( } _ => continue, }; + if own_field && copier.fields.contains(&r) { + split_own_field(dst, &mut copier.fields, r); + } annot_pages.insert(r, new); out.push(Object::Ref(r)); } @@ -805,6 +820,49 @@ fn qualified_optional(prefix: &str, name: &str) -> String { if name.is_empty() { prefix.to_string() } else { qualified(prefix, name) } } +/// Add `kid` to `field`'s `/Kids`, in the array object itself when `/Kids` is one, so the field +/// keeps its other widgets. +fn add_kid(dst: &mut Document, field: ObjRef, kid: ObjRef) -> Result<(), OrganizeError> { + match dst.get(field).as_dict().and_then(|f| f.get(b"Kids").cloned()) { + Some(Object::Ref(list)) => { + let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + dst.set(list, Object::Array(kids)); + } + _ => dst.update_dict(field, |f| { + let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + f.set(b"Kids".to_vec(), Object::Array(kids)); + })?, + } + Ok(()) +} + +/// PdfCraft patch (#1810): split a widget that doubles as its own field into a parent field +/// holding the field keys (name, value, flags…) and a widget child holding the annotation keys +/// (rectangle, appearance, page). The parent takes the widget's place among the top-level fields; +/// later copies of the page add widget children of it, so every page has its own widget and the +/// field stays one. +fn split_own_field(dst: &mut Document, fields: &mut [ObjRef], widget: ObjRef) { + let Some(d) = dst.get(widget).as_dict().cloned() else { return }; + let (mut field, mut child) = (Dict::new(), Dict::new()); + for (k, v) in d.iter() { + if WIDGET_KEYS.contains(&k.as_slice()) { + child.set(k.clone(), v.clone()); + } else { + field.set(k.clone(), v.clone()); + } + } + let parent = dst.add(Object::Null); + field.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(widget)])); + child.set(b"Parent".to_vec(), Object::Ref(parent)); + dst.set(parent, Object::Dict(field)); + dst.set(widget, Object::Dict(child)); + if let Some(entry) = fields.iter_mut().find(|f| **f == widget) { + *entry = parent; + } +} + /// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed). fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> { if fields.is_empty() { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index d34b1e431..c1e20a132 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -672,9 +672,40 @@ fn links_are_rewired_to_copied_pages_or_dropped() { assert!(!link.contains(b"Dest") && !link.contains(b"A")); } -/// A widget annotation that is its own form field (no `/Parent`). -fn own_field(doc: &Document, r: ObjRef) -> bool { - doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent")) +/// Issue #1810: a widget that is its own field was shared between both copies of a repeated +/// page, so only the last page could identify it (its `/P`), and per-page annotation edits were +/// impossible. Each copy must get its own widget sharing one parent field (name and value). +#[test] +fn repeated_page_splits_a_combined_field_widget() { + let out = full_roundtrip(&extract_pages(&doc_a(), &[1, 1]).unwrap()); + let ps = pages(&out).unwrap(); + let widget = |i: usize| -> ObjRef { + let list = page_dict(&out, i).get(b"Annots").map(|a| out.resolve(a).as_array().cloned().unwrap_or_default()).unwrap_or_default(); + list.iter().filter_map(|a| a.as_ref()).find(|r| out.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))).expect("a widget annotation") + }; + let (w1, w2) = (widget(0), widget(1)); + assert_ne!(w1, w2, "each copy of the page has its own widget"); + for (i, w) in [(0, w1), (1, w2)] { + let d = out.get(w).as_dict().unwrap().clone(); + assert_eq!(d.reference(b"P"), Some(ps[i].obj), "/P names the page holding the widget"); + let parent = d.reference(b"Parent").expect("the widget has a parent field"); + let f = out.get(parent).as_dict().unwrap().clone(); + assert_eq!(f.get(b"T").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("name")); + assert_eq!(f.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada")); + let kids: Vec = f.get(b"Kids").and_then(|k| out.resolve(k).as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); + assert!(kids.contains(&w1) && kids.contains(&w2), "one field holds both widgets: {kids:?}"); + let fields: Vec = catalog(&out) + .get(b"AcroForm") + .map(|f| out.resolve(f)) + .and_then(|f| f.as_dict().cloned()) + .and_then(|f| f.get(b"Fields").map(|x| out.resolve(x))) + .and_then(|f| f.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|f| f.as_ref()) + .collect(); + assert_eq!(fields, [parent], "the parent is the one top-level field"); + } } #[test] @@ -690,15 +721,11 @@ fn repeated_page_gets_independent_annotations() { assert!(!refs[i].is_empty()); assert_eq!(refs[i].len(), refs[i + 1].len()); for (a, b) in refs[i].iter().zip(&refs[i + 1]) { - // A widget that is its own field stays one object on both pages, so the field stays - // one (#790); every other annotation is copied. - if !own_field(&out, *a) { - assert_ne!(a, b, "each copy of the page has its own annotation objects"); - } + assert_ne!(a, b, "each copy of the page has its own annotation objects"); } } for (i, list) in refs.iter().enumerate() { - for a in list.iter().filter(|a| !own_field(&out, **a)) { + for a in list.iter() { assert_eq!(out.get(*a).as_dict().unwrap().reference(b"P"), Some(ps[i].obj), "/P names the page holding the copy"); } } @@ -719,7 +746,9 @@ fn form_fields_come_along_and_are_registered() { assert_eq!(fields.len(), 1); let field = out.resolve(&fields[0]).as_dict().cloned().unwrap(); assert_eq!(field.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada")); - assert_eq!(field.reference(b"P"), Some(pages(&out).unwrap()[0].obj)); + // The widget child, not the field, names the page (#1810). + let kid = field.get(b"Kids").map(|k| out.resolve(k)).and_then(|k| k.as_array().cloned()).and_then(|k| k.first().and_then(|k| k.as_ref())).unwrap(); + assert_eq!(out.get(kid).as_dict().unwrap().reference(b"P"), Some(pages(&out).unwrap()[0].obj)); } #[test] @@ -2547,8 +2576,9 @@ fn import_renames_a_field_colliding_with_another_incoming_field() { assert_eq!(form_field_names(&dst), ["Other", "Text1", "Text1-1"]); } -/// A page listed twice shows its widget on both copies — also when the field was renamed: both -/// pages' widgets belong to the one renamed field, and no second Text1 appears. +/// A page listed twice shows its widget on both copies — also when the field was renamed: each +/// copy has its own widget, both widgets belong to the one renamed field (#1810), and no second +/// Text1 appears. #[test] fn a_repeated_page_joins_the_renamed_field() { let mut dst = doc_with_field("Text1"); @@ -2565,10 +2595,18 @@ fn a_repeated_page_joins_the_renamed_field() { .and_then(|l| l.as_array().cloned()) .unwrap(); let renamed = fields[1].clone(); + let mut widgets = Vec::new(); for page in [1, 2] { let list = page_dict(&dst, page).get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap(); - assert!(list.contains(&renamed), "page {page}'s widget belongs to the renamed field"); + let widget = list.iter().find_map(|a| a.as_ref()).expect("a widget on the page"); + widgets.push(widget); + assert_eq!( + dst.get(widget).as_dict().unwrap().reference(b"Parent"), + renamed.as_ref(), + "page {page}'s widget belongs to the renamed field" + ); } + assert_ne!(widgets[0], widgets[1], "each copy has its own widget"); } /// Combining forms keeps same-named fields separate — the second file's colliding field is From 32ca91a31f1ee222d48c377e9ae24db6482c724e Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Fri, 16 Oct 2026 19:00:02 +0200 Subject: [PATCH 02/59] M5: sticky notes stay upright where they are placed on rotated pages (#2583) Notes and file attachments are saved with the NoRotate flag (/F bit 5), but nothing honoured it: on a page with /Rotate the icon was fitted to /Rect and turned with the page, and sat away from where it was placed. The vendored renderer now honours NoRotate (hayro-interpret patch 37): the appearance fitted to /Rect is turned against the page's rotation about the rectangle's upper-left corner, which stays put (ISO 32000-2 12.5.3). Form fields and links stay in /Rect, where their click targets come from. A counterrotated appearance, as stamps use, would be turned twice by viewers that honour the flag. Annotation::shown_rect gives the box the note is drawn in. The UI uses it for hit-testing, outlines, moving, the hover pop-up, the Comments panel flash and the edit composer; a resize maps it back to /Rect. comment_add anchors an icon's displayed top-left at `at`, and comment_list, doc_info and comment_edit use the drawn box. This also covers the sticky-note part of #1869. --- ROADMAP.md | 1 + crates/annot/README.md | 5 + crates/annot/src/lib.rs | 14 +++ crates/automation/src/comments.rs | 23 ++-- crates/automation/src/lib.rs | 3 +- crates/engine/src/lib.rs | 1 + crates/engine/src/tests.rs | 41 +++++++ crates/render/src/inspect.rs | 115 ++++++++++++++++++++ crates/render/src/raster.rs | 85 +++++++++++++++ crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/comments.rs | 32 ++++-- crates/ui-egui/src/comments_panel.rs | 7 +- crates/ui-egui/tests/comments.rs | 32 ++++++ parity/acrobat-features.toml | 4 +- vendor/README.md | 2 +- vendor/hayro-interpret/src/interpret/mod.rs | 34 +++++- 16 files changed, 373 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec0..6bb2c0833 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-16 (M5, #2583, #1869):** A sticky note on a page with `/Rotate` is drawn upright, with its top-left where it was placed. Notes are saved with the NoRotate flag, and the renderer now honours it: the appearance is turned against the page's rotation about the upper-left corner of `/Rect`, which stays put (ISO 32000-2 §12.5.3; vendored hayro-interpret patch 37). Attachment icons follow the same rule. Selection, hover, the edit composer, the Comments panel's flash and the `comment_list` and `doc_info` rectangles use the drawn box, and `comment_add` anchors an icon's displayed top-left at `at`. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/crates/annot/README.md b/crates/annot/README.md index 8ba3ca8c4..55df6d1a7 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -51,6 +51,11 @@ deterministic; the engine supplies them. signature) and PdfCraft's rubber stamps counterrotate their appearance on rotated pages, so the picture or label reads upright as the page is displayed. Restyling a picture stamp keeps that appearance matrix. The rotation is the page's `/Rotate` at placement. +- Sticky notes and file attachments are not counterrotated: they carry the NoRotate flag and + their icon is drawn in an unturned 20 × 20 box. A viewer keeps them upright on a turned page, + about the upper-left corner of `/Rect` (ISO 32000-2 §12.5.3). PdfCraft's renderer does so + (vendored hayro-interpret patch 37), and `Summary::no_rotate` tells the UI where the icon is + drawn. ## Not yet diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index f4973aa02..590848cb2 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -751,6 +751,17 @@ fn annotation_flags(doc: &Document, d: &Dict) -> i64 { d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) } +/// Whether the renderer keeps the annotation upright on a turned page: the NoRotate flag, with +/// `/F` read as the renderer reads it (a real truncated, a value that isn't a `u32` no flags). +fn drawn_upright(doc: &Document, d: &Dict) -> bool { + let f = match d.get(b"F").map(|f| doc.resolve(f)).as_deref() { + Some(Object::Int(i)) => *i, + Some(Object::Real(r)) => *r as i64, + _ => 0, + }; + u32::try_from(f).is_ok_and(|f| i64::from(f) & FLAG_NO_ROTATE != 0) +} + /// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden). pub fn has_visible_fill_sign(doc: &Document) -> bool { let Ok(pages) = page_refs(doc) else { return false }; @@ -2082,6 +2093,8 @@ pub struct Summary { pub intent: Option, /// A Fill & Sign mark ([`is_fill_sign`]). pub fill_sign: bool, + /// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`. + pub no_rotate: bool, } fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { @@ -2155,6 +2168,7 @@ pub fn summaries(doc: &Document) -> Vec { locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), fill_sign: is_fill_sign(doc, d), + no_rotate: drawn_upright(doc, d), }); } } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 302688f72..109e4acf3 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -3,9 +3,7 @@ //! Geometry follows the automation convention: points from the top-left of the displayed page, //! y down. It is converted to PDF user space (crop box, `/Rotate`) here. -use pdfcraft_engine::{ - Edit, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort, -}; +use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort}; use pdfcraft_render::{Annotation, PageInfo}; use serde_json::{Value, json}; @@ -79,13 +77,11 @@ fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] { } /// The engine's `at` for a note or attachment icon whose top-left corner *as displayed* is the -/// view point (x, y): the top-left (`[x0, y1]`) of the icon's square in user space. Converting -/// the point alone is only right on unrotated pages — under `/Rotate` that corner of the -/// displayed square is another corner of the user-space one, and the icon lands one icon-width -/// away from where it was asked for. +/// view point (x, y): the upper-left corner (`[x0, y1]`) of its `/Rect`. Notes and attachments +/// are NoRotate, so on a turned page the icon is drawn upright with its top-left at that corner +/// (ISO 32000-2 §12.5.3), and `comment_list` reports that square (`Annotation::shown_rect`). fn icon_anchor(p: &PageInfo, x: f64, y: f64) -> [f64; 2] { - let r = rect_to_user(p, [x, y, x + NOTE_SIZE, y + NOTE_SIZE]); - [r[0], r[3]] + to_user(p, x, y) } /// A user-space rectangle as the displayed-page rectangle every tool reports and accepts: @@ -182,7 +178,7 @@ impl Automation { "author": c.author, "contents": c.contents, "modified": c.modified, - "rect": rect_to_view(p, c.rect), + "rect": rect_to_view(p, c.shown_rect(p.rotation)), "color": c.color.map(hex), }) }; @@ -626,7 +622,12 @@ impl Automation { edits.push(Edit::FontSizeAnnotation { page, index, size }); } if let Some(r) = a.nums::<4>("rect")? { - edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) }); + // The rectangle as comment_list reports it: where the comment is drawn. + let doc = self.doc(a)?; + let rect = rect_to_user(&info, r); + let rect = + doc.info.annotations.iter().find(|c| c.page == page && c.index == index).map_or(rect, |c| c.rect_for_shown(rect, info.rotation)); + edits.push(Edit::ResizeAnnotation { page, index, rect }); } if let Some([dx, dy]) = a.nums::<2>("move")? { let (o, d) = (to_user(&info, 0.0, 0.0), to_user(&info, dx, dy)); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 88856305b..e1abeaa6c 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2417,7 +2417,8 @@ fn info(d: &Document) -> Value { // geometry-taking tools (#129). "annotations": i.annotations.iter().map(|a| json!({ "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, - "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, "rect": view_rect(i, a.page, a.rect), + "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, + "rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))), })).collect::>(), "fields": i.fields.iter().map(|f| json!({ "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3432cd868..fd8c66cac 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2710,6 +2710,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec Vec<[u8; 4]> { + (80..120).flat_map(|y| (200..240).map(move |x| (x, y))).map(|(x, y)| stamp_pixel(rendered, x, y)).collect() + }; + let mut upright = None; + for degrees in [0, 90, 180, 270] { + let (mut session, id) = rotated_blank(degrees); + let info = session.get(id).unwrap().info.pages[0].clone(); + let [x, y] = info.view_to_user(100.0, 40.0); + let shape = Shape::Note { at: [f64::from(x), f64::from(y)], icon: NoteIcon::Comment }; + let style = Style::default_for(&shape); + session.apply(id, Edit::AddAnnotation(NewAnnotation { page: 0, style, shape, contents: "Turned".into(), author: "Test".into() })).unwrap(); + let mut check = |session: &Session, id: DocId| { + let doc = session.get(id).unwrap(); + let info = &doc.info.pages[0]; + let note = &doc.info.annotations[0]; + assert!(note.no_rotate, "rotation {degrees}: notes are saved NoRotate"); + let shown = note.shown_rect(info.rotation); + let (a, b) = (info.user_to_view(shown[0], shown[1]), info.user_to_view(shown[2], shown[3])); + let shown = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + assert!(shown.iter().zip([100.0, 40.0, 120.0, 60.0]).all(|(v, w)| (v - w).abs() < 0.01), "rotation {degrees}: shown at {shown:?}"); + let icon = crop(&render_page(session, id)); + let reference = upright.get_or_insert_with(|| icon.clone()); + assert!(reference.iter().filter(|c| c.iter().any(|v| *v < 200)).count() > 100, "the note's icon is drawn"); + let off = icon.iter().zip(reference.iter()).filter(|(a, b)| a.iter().zip(b.iter()).any(|(a, b)| a.abs_diff(*b) > 48)).count(); + assert!(off <= 8, "rotation {degrees}: {off} of the icon's {} pixels differ from the unturned page's", icon.len()); + }; + check(&session, id); + let saved = session.save_bytes(id).unwrap(); + let mut reopened = Session::new().with_clock(|| 1_700_000_000); + let again = reopened.open("drawing.pdf", None, saved, None).unwrap(); + check(&reopened, again); + } +} + /// Fill & Sign text and text boxes read across as displayed on turned pages (scanned forms are /// often stored sideways with `/Rotate`), including after save. #[test] diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 3c3988b7b..fb2b5a9c7 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -383,6 +383,10 @@ pub struct Annotation { /// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture /// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`). pub fill_sign: bool, + /// The NoRotate flag (`/F` bit 5), set on sticky notes and attachments: on a turned page the + /// appearance stays upright, turned about the upper-left corner of `rect` + /// ([`Self::shown_rect`]). + pub no_rotate: bool, } impl Annotation { @@ -390,6 +394,58 @@ impl Annotation { pub fn is_mark(&self) -> bool { matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) } + + /// Where the comment is drawn on a page turned by `rotation` (`PageInfo::rotation`), in user + /// space: `rect`, or for a NoRotate comment `rect` turned upright about its upper-left corner + /// (see [`Self::no_rotate`]). Hit-testing, selection and anything placed beside the comment + /// use this, so they match the drawing. + pub fn shown_rect(&self, rotation: u16) -> [f32; 4] { + self.shown(self.rect.map(f64::from), rotation).map(|v| v as f32) + } + + /// [`Self::shown_rect`] for this comment with its `/Rect` at `rect` (one being moved or + /// resized). + pub fn shown(&self, rect: [f64; 4], rotation: u16) -> [f64; 4] { + if self.no_rotate { upright_rect(rect, rotation) } else { rect } + } + + /// The `/Rect` that shows this comment in the user-space box `shown`: the inverse of + /// [`Self::shown`], for a box resized as drawn. + pub fn rect_for_shown(&self, shown: [f64; 4], rotation: u16) -> [f64; 4] { + if self.no_rotate { rect_from_upright(shown, rotation) } else { shown } + } +} + +/// The user-space box a NoRotate annotation with `/Rect` `rect` is drawn in on a page turned +/// clockwise by `rotation` (ISO 32000-2 §12.5.3): `rect` turned as far counterclockwise about its +/// upper-left corner `(x0, y1)`, which stays where it is on the page. As displayed, the box's +/// top-left is that corner, the rectangle's width runs across and its height down, as on an +/// unturned page. `rect` itself for 0 or anything that isn't a quarter turn. The vendored +/// renderer draws the appearance there (hayro-interpret patch 37). +fn upright_rect(rect: [f64; 4], rotation: u16) -> [f64; 4] { + let [x0, y0, x1, y1] = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let (w, h) = (x1 - x0, y1 - y0); + match rotation { + 90 => [x0, y1, x0 + h, y1 + w], + 180 => [x0 - w, y1, x0, y1 + h], + 270 => [x0 - h, y1 - w, x0, y1], + _ => [x0, y0, x1, y1], + } +} + +/// The `/Rect` whose [`upright_rect`] is `shown`: `shown` turned back about the corner that +/// stays put. +fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] { + let [s0, s1, s2, s3] = [shown[0].min(shown[2]), shown[1].min(shown[3]), shown[0].max(shown[2]), shown[1].max(shown[3])]; + let (sw, sh) = (s2 - s0, s3 - s1); + // The upper-left corner of `/Rect` and the rectangle's width and height. + let ((x0, y1), w, h) = match rotation { + 90 => ((s0, s1), sh, sw), + 180 => ((s2, s1), sw, sh), + 270 => ((s2, s3), sh, sw), + _ => return [s0, s1, s2, s3], + }; + [x0, y1 - h, x0 + w, y1] } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1141,6 +1197,7 @@ impl<'a> Inspector<'a> { locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, intent: self.name(d, b"IT"), fill_sign, + no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0), }); } } @@ -1554,6 +1611,17 @@ fn close_outline(open: &mut Vec<(usize, OutlineItem)>, roots: &mut Vec u32 { + let f = match o { + Object::Integer(i) => *i, + Object::Real(r) => *r as i64, + _ => 0, + }; + u32::try_from(f).unwrap_or(0) +} + fn rect4(o: &Object) -> [f32; 4] { match o { Object::Array(a) if a.len() == 4 => { @@ -1900,6 +1968,53 @@ mod tests { assert_eq!(p.view_rect_to_quad([0.0, 0.0, 10.0, 5.0]), [10.0, 320.0, 20.0, 320.0, 10.0, 315.0, 20.0, 315.0]); } + /// #2583: a NoRotate comment is drawn upright with its top-left as displayed at the upper-left + /// corner of `/Rect`, its width across and its height down; a box resized as drawn maps back to + /// the `/Rect` that shows it. Other comments, and pages that aren't quarter-turned, keep `/Rect`. + #[test] + fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() { + let mut note = super::Annotation { + page: 0, + subtype: "Text".into(), + author: None, + contents: None, + modified: None, + name: None, + in_reply_to: None, + rect: [40.0, 50.0, 70.0, 70.0], + color: None, + index: 0, + state: None, + quads: Vec::new(), + locked: false, + intent: None, + fill_sign: false, + no_rotate: true, + }; + let rect = note.rect.map(f64::from); + for rotation in [0u16, 90, 180, 270, 45] { + let (w, h) = if rotation % 180 == 90 { (140.0, 200.0) } else { (200.0, 140.0) }; + let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation }; + let shown = note.shown_rect(rotation); + let (a, b) = (p.user_to_view(shown[0], shown[1]), p.user_to_view(shown[2], shown[3])); + let [cx, cy] = p.user_to_view(40.0, 70.0); + let want = [cx, cy, cx + 30.0, cy + 20.0]; + let got = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{rotation}: shown {got:?}, want {want:?}"); + assert_eq!(note.rect_for_shown(note.shown(rect, rotation), rotation), rect, "{rotation}"); + // Moved by (5, -7) and resized to 50 × 10 as drawn, back to a /Rect. + let resized = [shown[0] as f64 + 5.0, shown[1] as f64 - 7.0, 0.0, 0.0]; + let across = if rotation % 180 == 90 { [10.0, 50.0] } else { [50.0, 10.0] }; + let resized = [resized[0], resized[1], resized[0] + across[0], resized[1] + across[1]]; + let back = note.rect_for_shown(resized, rotation); + assert_eq!((back[2] - back[0], back[3] - back[1]), (50.0, 10.0), "{rotation}: {back:?}"); + assert_eq!(note.shown(back, rotation), resized, "{rotation}"); + } + note.no_rotate = false; + assert_eq!(note.shown_rect(90), note.rect); + assert_eq!(note.rect_for_shown([1.0, 2.0, 3.0, 4.0], 90), [1.0, 2.0, 3.0, 4.0]); + } + use super::*; #[test] diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 8d5686dd8..04f2f46b6 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -3034,6 +3034,91 @@ trailer << /Root 1 0 R >> } } + /// #2583, #1869: a sticky note carries the NoRotate flag (`/F` bit 5), so on a page with + /// `/Rotate` its icon stays upright as displayed, turned about the upper-left corner of its + /// `/Rect`, which stays where it is on the page (ISO 32000-2 §12.5.3). It was drawn turned with + /// the page, in `/Rect` as displayed (vendored hayro-interpret patch 37). A note without the + /// flag still turns with the page. `Annotation::shown_rect` is where the UI hit-tests the + /// note, and must be where it is drawn. + #[test] + fn no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages() { + // A 30 × 20 appearance: red in its top-left quarter, blue elsewhere. + let content = "0 0 1 rg 0 0 30 20 re f 1 0 0 rg 0 10 15 10 re f"; + for rotation in [0u16, 90, 180, 270] { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 140] /Rotate {rotation} /Annots [4 0 R 5 0 R] >>"), + // NoRotate (and Print), as PdfCraft and Acrobat save notes. + "<< /Type /Annot /Subtype /Text /F 20 /Rect [40 50 70 70] /AP << /N 6 0 R >> >>".to_string(), + // The same appearance without the flag. + "<< /Type /Annot /Subtype /Text /F 4 /Rect [120 50 150 70] /AP << /N 6 0 R >> >>".to_string(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 30 20] /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let pdf = Arc::new(pdf); + let info = crate::inspect(pdf.clone(), None).expect("opens"); + let page = &info.pages[0]; + assert_eq!(page.rotation, rotation); + let mut r = PageRenderer::new(pdf, RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + // Displayed-page points are device pixels at scale 1. + let px = |x: f32, y: f32| -> [u8; 4] { + let (x, y) = (x.clamp(0.0, p.width as f32 - 1.0) as u32, y.clamp(0.0, p.height as f32 - 1.0) as u32); + p.rgba[((y * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + let red: fn([u8; 4]) -> bool = |c| c[0] > 200 && c[1] < 60 && c[2] < 60; + let blue: fn([u8; 4]) -> bool = |c| c[2] > 200 && c[0] < 60 && c[1] < 60; + let paper: fn([u8; 4]) -> bool = |c| c.iter().all(|v| *v > 240); + + // The NoRotate note: its upper-left corner (40, 70) is the top-left of an upright + // 30 × 20 icon as displayed, red quarter top-left. + let [cx, cy] = page.user_to_view(40.0, 70.0); + for ((dx, dy), want, what) in [ + ((7.5, 5.0), red, "red top-left quarter"), + ((22.5, 5.0), blue, "blue top-right"), + ((7.5, 15.0), blue, "blue bottom-left"), + ((22.5, 15.0), blue, "blue bottom-right"), + ((-3.0, 10.0), paper, "paper left of the icon"), + ((10.0, -3.0), paper, "paper above the icon"), + ((33.0, 10.0), paper, "paper right of its 30 pt width"), + ((10.0, 23.0), paper, "paper below its 20 pt height"), + ] { + let c = px(cx + dx, cy + dy); + assert!(want(c), "rotation {rotation}: NoRotate note at corner + ({dx}, {dy}) is {c:?}, want {what}"); + } + let note = info.annotations.iter().find(|a| a.no_rotate).expect("the NoRotate note is listed"); + let shown = note.shown_rect(rotation); + let (a, b) = (page.user_to_view(shown[0], shown[1]), page.user_to_view(shown[2], shown[3])); + let box_ = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + let want = [cx, cy, cx + 30.0, cy + 20.0]; + assert!(box_.iter().zip(want).all(|(v, w)| (v - w).abs() < 0.01), "rotation {rotation}: shown {box_:?}, drawn {want:?}"); + + // The note without the flag turns with the page: its red quarter stays at the + // user-space top-left of /Rect, at the displayed top-left only when unturned. + let plain = info.annotations.iter().find(|a| !a.no_rotate).expect("the plain note is listed"); + assert_eq!(plain.shown_rect(rotation), plain.rect); + let [rx, ry] = page.user_to_view(127.5, 65.0); + let [bx, by] = page.user_to_view(142.5, 55.0); + assert!(red(px(rx, ry)) && blue(px(bx, by)), "rotation {rotation}: the plain note's appearance turns with the page"); + let (a, b) = (page.user_to_view(120.0, 50.0), page.user_to_view(150.0, 70.0)); + let corner = px(a[0].min(b[0]) + 4.0, a[1].min(b[1]) + 4.0); + assert_eq!(red(corner), rotation == 0, "rotation {rotation}: displayed top-left of the plain note is {corner:?}"); + } + } + /// #1968: the highlight appearance other editors write, a transparency group whose content is /// filled with `/BM /Multiply`, drew as an opaque block over the text. Every `/Group` form /// got a layer of its own, so the Multiply blended with that layer's transparent backdrop diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0acab64a0..3704a98ea 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3354,7 +3354,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser)); let gesturing = view.comments.gesture.is_some() || freehand; for a in info.annotations.iter().filter(|a| a.page == i && !gesturing) { - let sr = xf.user_rect(info, i, a.rect); + let sr = xf.user_rect(info, i, a.shown_rect(info.pages.get(i).map_or(0, |p| p.rotation))); let hidden = prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty); if sr.contains(p) && hover_text.is_none() @@ -5141,6 +5141,7 @@ mod tests { locked: false, intent: None, fill_sign, + no_rotate: false, } } diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 5a708c14f..b677431fe 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -553,8 +553,14 @@ impl PageCx<'_> { self.xf.norm_to_screen(v[0] / self.xf.pw, v[1] / self.xf.ph) } + /// The page's clockwise `/Rotate`. + fn rotation(&self) -> u16 { + self.info.pages.get(self.page).map_or(0, |p| p.rotation) + } + + /// Where a comment is drawn on screen (a sticky note stays upright on a turned page). fn screen_rect(&self, a: &Annotation) -> Rect { - self.xf.user_rect(self.info, self.page, a.rect) + self.xf.user_rect(self.info, self.page, a.shown_rect(self.rotation())) } /// Top-level comments on this page, in paint order. @@ -625,16 +631,13 @@ impl PageCx<'_> { if let Some(Edit::ResizeAnnotation { page, index, rect }) = pending && (*page, *index) == (self.page, a.index) { - return self.xf.user_rect(self.info, self.page, rect.map(|v| v as f32)); + return self.xf.user_rect(self.info, self.page, a.shown(*rect, self.rotation()).map(|v| v as f32)); } if let Some(Edit::MoveAnnotation { page, index, dx, dy }) = pending && (*page, *index) == (self.page, a.index) { - return self.xf.user_rect( - self.info, - self.page, - [a.rect[0] + *dx as f32, a.rect[1] + *dy as f32, a.rect[2] + *dx as f32, a.rect[3] + *dy as f32], - ); + let r = a.shown_rect(self.rotation()); + return self.xf.user_rect(self.info, self.page, [r[0] + *dx as f32, r[1] + *dy as f32, r[2] + *dx as f32, r[3] + *dy as f32]); } match (gesture, pointer) { (Some(Gesture::Move { page, index, from }), Some(p)) if (*page, *index) == (self.page, a.index) => r.translate(p - *from), @@ -1091,7 +1094,7 @@ fn select_input( let from_rect = cx.screen_rect(a); let shift = ui.input(|i| i.modifiers.shift); let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift)); - let rect = cx.rect_to_user(r); + let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation()); if r.width() >= 4.0 && r.height() >= 4.0 { view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect }); } @@ -1131,7 +1134,7 @@ fn select_input( cv.selected = Some((cx.page, a.index)); cv.composer = Some(Composer { page: cx.page, - at: [a.rect[2] as f64, a.rect[3] as f64], + at: edit_anchor(cx.info, a), kind: ComposerKind::Edit(a.index), text: a.contents.clone().unwrap_or_default(), focus: true, @@ -1332,6 +1335,15 @@ fn erase(cx: &PageCx<'_>, path: &[[f64; 2]]) -> Option { Some(Edit::Batch { label: "Erase".into(), edits }) } +/// Where the composer for editing `a` opens: the top-right corner of the comment as drawn on the +/// displayed page. +fn edit_anchor(info: &DocInfo, a: &Annotation) -> [f64; 2] { + let Some(p) = info.pages.get(a.page) else { return [f64::from(a.rect[2]), f64::from(a.rect[3])] }; + let r = a.shown_rect(p.rotation); + let (u, v) = (p.user_to_view(r[0], r[1]), p.user_to_view(r[2], r[3])); + p.view_to_user(u[0].max(v[0]), u[1].min(v[1])).map(f64::from) +} + fn new_comment(cx: &PageCx<'_>, tool: CommentTool, shape: Shape, contents: String) -> Edit { Edit::AddAnnotation(NewAnnotation { page: cx.page, shape, style: cx.prefs.style(tool), contents, author: cx.prefs.author.clone() }) } @@ -1588,7 +1600,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() { view.comments.composer = Some(Composer { page, - at: [a.rect[2] as f64, a.rect[3] as f64], + at: edit_anchor(info, a), kind: ComposerKind::Edit(index), text: a.contents.clone().unwrap_or_default(), focus: true, diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index c5fbbd0ce..482d40e08 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -255,7 +255,8 @@ pub(crate) fn show( continue; } let thread = replies_of(a); - if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) { + let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation); + if let Some(e) = card(ui, t, a, rotation, &thread, view, prefs, allowed, nav) { edit = Some(e); } } @@ -267,6 +268,8 @@ fn card( ui: &mut egui::Ui, t: &Tokens, a: &Annotation, + // The clockwise `/Rotate` of the comment's page. + rotation: u16, thread: &[&Annotation], view: &mut DocView, prefs: &CommentPrefs, @@ -430,7 +433,7 @@ fn card( if click.clicked() { view.comments.selected = Some(key); view.comments.reply.clear(); - *nav = Some(Nav::Flash(a.page, a.rect)); + *nav = Some(Nav::Flash(a.page, a.shown_rect(rotation))); } if click.double_clicked() && allowed { view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default())); diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 914bc281b..f479aebe4 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -488,6 +488,38 @@ fn sticky_note_composer_posts_and_returns_to_select() { assert_eq!(comments(&h).len(), 1); } +/// #2583: a sticky note is saved NoRotate, so on a turned page its icon is drawn upright from +/// where it was placed (right and down), not where its `/Rect` lies as displayed. The Select tool +/// picks it where it is drawn. +#[test] +fn a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn() { + let mut h = harness(|app| { + app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + app.set_option("quick", "note").unwrap(); + }); + settle(&mut h); + let page = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + // Screen pixels per point of the displayed page (300 × 200 pt turned to 200 × 300). + let k = page.width() / 200.0; + let placed = page.left_top() + egui::vec2(100.0 * k, 60.0 * k); + click(&mut h, placed); + field(&h, "Add a comment").type_text("Turned"); + h.run_steps(2); + h.get_by_label("Post").click(); + h.run_steps(3); + settle(&mut h); + let c = comments(&h); + assert_eq!(c.len(), 1); + assert_eq!((c[0].subtype.as_str(), c[0].no_rotate), ("Text", true)); + assert_eq!(h.state().quick_tool, QuickTool::Select); + // Where the note's /Rect lies as displayed, left of where it was placed: nothing. + click(&mut h, placed + egui::vec2(-10.0 * k, 10.0 * k)); + assert_eq!(h.state().views[0].comments.selected, None); + // On the icon, drawn right and down from where it was placed. + click(&mut h, placed + egui::vec2(10.0 * k, 10.0 * k)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0))); +} + #[test] fn comments_are_selected_moved_and_deleted_with_the_select_tool() { let mut h = harness(|app| app.set_option("quick", "square").unwrap()); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94..72ee77962 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3572,8 +3572,8 @@ milestone = "M5" status = "shipped" commands = ["comment.note"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"] -notes = "our own Comment/Note icon drawings, with a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline" +evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages", "crates/render/src/raster.rs::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages", "crates/ui-egui/tests/comments.rs::a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn"] +notes = "our own Comment/Note icon drawings, with a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline. Notes are saved with the NoRotate flag, and it is honoured: on a page with /Rotate the icon stays upright with its top-left where it was placed, and is selected and hovered there (#2583). Missing: NoZoom (the icon scales with the zoom); printing and flattening still place the icon in its /Rect, turned with the page" [[feature]] id = "comment.highlight" diff --git a/vendor/README.md b/vendor/README.md index 62be4cad9..6a815d22b 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -9,7 +9,7 @@ A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (37) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (37) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` | | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index fe08fc284..a74f417f3 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -21,7 +21,7 @@ use hayro_syntax::object::dict::keys::{ ACTUAL_TEXT, ANNOTS, AP, AS, C, CA, F, MCID, N, OC, QUADPOINTS, RECT, SUBTYPE, }; use hayro_syntax::object::{Array, Dict, Object, Rect, Stream, dict_or_stream}; -use hayro_syntax::page::{ContentReader, Page, Resources}; +use hayro_syntax::page::{ContentReader, Page, Resources, Rotation}; use hayro_syntax::MAX_DECODED_STREAM; use kurbo::{Affine, BezPath, Point, Shape}; use smallvec::smallvec; @@ -249,6 +249,13 @@ pub fn interpret_page<'a>( annot_rect.x0 - sx * transformed_rect.x0, annot_rect.y0 - sy * transformed_rect.y0, ]); + // PdfCraft patch (37): NoRotate (bit 5) keeps the appearance upright as the page is + // displayed (ISO 32000-2 §12.5.3). + let affine = if flags & 16 != 0 && no_rotate_applies(&annot) { + no_rotate_turn(page.rotation(), &annot_rect) * affine + } else { + affine + }; // PdfCraft patch: a /BBox or /Matrix that collapses the appearance box to a line // or a point leaves nothing to map onto /Rect: the scales above divide by zero, @@ -282,6 +289,31 @@ pub fn interpret_page<'a>( } } +/// PdfCraft patch (37): whether the NoRotate flag turns this annotation's appearance. Form fields +/// and links are left in `/Rect`: PdfCraft takes their click targets from it. +fn no_rotate_applies(annot: &Dict<'_>) -> bool { + !matches!( + annot.get::>(SUBTYPE).as_deref(), + Some(b"Widget" | b"Link") + ) +} + +/// PdfCraft patch (37): the user-space turn that keeps a NoRotate annotation upright on a page +/// with `/Rotate`. The page is shown turned clockwise, so the appearance (already fitted to +/// `/Rect`) is turned as far counterclockwise about the rectangle's upper-left corner, which stays +/// where it is on the page (ISO 32000-2 §12.5.3). Its width then runs across the displayed page +/// and its height down from that corner. +fn no_rotate_turn(rotation: Rotation, rect: &kurbo::Rect) -> Affine { + let (cos, sin) = match rotation { + Rotation::None => return Affine::IDENTITY, + Rotation::Horizontal => (0.0, 1.0), + Rotation::Flipped => (-1.0, 0.0), + Rotation::FlippedHorizontal => (0.0, -1.0), + }; + let (x, y) = (rect.x0.min(rect.x1), rect.y0.max(rect.y1)); + Affine::new([cos, sin, -sin, cos, x - (cos * x - sin * y), y - (sin * x + cos * y)]) +} + /// PdfCraft patch: the normal appearance of a Highlight annotation that has none (ISO 32000-2 /// §12.5.6.10), drawn as PdfCraft draws its own highlights (pdfcraft-annot): each /QuadPoints /// quadrilateral filled with /C at /CA opacity, blended with Multiply so the marked-up content From 755266cf9f93b2a7cf1a221e342b733917d8f7c3 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 21 Oct 2026 18:45:04 +0500 Subject: [PATCH 03/59] cli: the argument bugs photocraft#7071 lists are live here too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of them, measured on main: pdfcraft-cli extract f.pdf --pages 1 --out --password (exit 0, and the PDF is in a file called `--password`) pdfcraft-cli check-one --edit f.pdf pdfcraft-cli: check-one: missing file pdfcraft-cli info /tmp/café.pdf (that é as one Latin-1 byte) thread 'main' panicked at library/std/src/env.rs:878:51: called `Result::unwrap()` on an `Err` value - An option whose value is a **file or folder** refuses a value that is another option, and names the way out for a file that really starts with `-`: `./--name`, as `--root` already offered. That also closes the gap the option walk left for those: an unknown option in a value position was skipped along with the option it followed, so `combine --out --nonsense` wrote `--nonsense` instead of saying what `--nonsense` was. Only those options. A password or a title can be any string, `--page` included — `an_option_shaped_password_is_not_parsed_as_a_page_flag` pins that, and a bad `--page` or `--dpi` is reported by the command in its own terms either way. - `positional` asks the option table which tokens are values, instead of skipping the one after every option. The token after a flag that takes none is an argument: `check-one --edit f.pdf` read the file as `--edit`'s operand and then reported it missing. - Arguments come through `args_os`, so one that is not UTF-8 is a usage error rather than a panic. `std::env::args` panics while collecting them, so even `--help` ended that way. tests/options.rs covers each, and each fails without its fix (the file called `--password` comes back; "check-one: missing file"; exit 101 with that report). --- apps/pdfcraft-cli/src/main.rs | 116 ++++++++++++++++++++++------- apps/pdfcraft-cli/tests/options.rs | 81 ++++++++++++++++++++ 2 files changed, 169 insertions(+), 28 deletions(-) diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 5888e5111..3fd67e3bb 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -84,7 +84,24 @@ help and feedback: https://discord.gg/artcraft" ); fn main() -> ExitCode { - let args: Vec = std::env::args().skip(1).collect(); + // `std::env::args` panics on an argument that is not UTF-8 ("called `Result::unwrap()` on an + // `Err` value"), which a Latin-1 file name is, and it panics while collecting them: even + // `--help` ended that way. Such a name cannot be passed on either, since the paths here are + // `str`, so it is a usage error (photocraft#1108). + let mut args: Vec = Vec::new(); + for arg in std::env::args_os().skip(1) { + match arg.into_string() { + Ok(a) => args.push(a), + Err(bad) => { + let _ = writeln!( + std::io::stderr().lock(), + "pdfcraft-cli: `{}` is not valid UTF-8, so it cannot be used as an argument", + bad.to_string_lossy() + ); + return ExitCode::FAILURE; + } + } + } // ` --help` too; only right after the command, so a later `-h` (a value) still reaches it. let command = match args.get(1).map(String::as_str) { Some("--help" | "-h") => Some("--help"), @@ -169,7 +186,7 @@ fn version() -> Result<(), CliError> { /// found so scripts and CI can gate on it, and when the audit itself cannot run. fn audit(args: &[String]) -> Result<(), CliError> { validate_options("audit", args, AUDIT_OPTIONS)?; - let path = *positional(args).first().ok_or("audit: missing file")?; + let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; @@ -203,6 +220,28 @@ struct OptionSpec { takes_value: bool, } +/// The options whose value is a file or folder. Only these refuse a value that is another +/// option: a file named `--password` is never what was meant, while a password can be anything +/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title. +/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered. +const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"]; + +/// Whether a token can be the value of a path option. Another option cannot: `--out --password` +/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944). +fn can_be_path_value(name: &str, value: Option<&str>) -> bool { + !PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--")) +} + +/// The complaint about a path option whose value is missing or is another option. +fn needs_value(command: &str, name: &str, value: Option<&str>) -> String { + match value { + Some(v) if !v.is_empty() => { + format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name") + } + _ => format!("{command}: {name} needs a value"), + } +} + /// Refuse unknown options and malformed confinement flags before any file is read. fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> { if specs.iter().any(|spec| spec.name == "--root") { @@ -225,9 +264,19 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { return Err(format!("{command}: unknown option --{name}").into()); }; - // Other missing or malformed values are left to the command, which reports them in its own - // terms (`text` and `extract` explain what a page value must be). - i += if spec.takes_value { 2 } else { 1 }; + // A file's name is not another option, which also closes the gap the walk left for + // those: an unknown option in that value position was skipped along with it. Other + // values, missing or malformed, are left to the command, which reports them in its + // own terms (`text` and `extract` explain what a page value must be). + i += if spec.takes_value { + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(spec.name, value) { + return Err(needs_value(command, spec.name, value).into()); + } + 2 + } else { + 1 + }; } else { i += 1; } @@ -273,19 +322,24 @@ const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: #[cfg(feature = "mcp")] const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }]; -fn positional(args: &[String]) -> Vec<&str> { +/// The arguments that are not options or their values. Which tokens are values is the spec's +/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing +/// file", the file having been read as `--edit`'s value. +fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> { let mut out = Vec::new(); - let mut skip = false; - for a in args { - if skip { - skip = false; - continue; + let mut i = 0; + while let Some(a) = args.get(i) { + match specs.iter().find(|spec| spec.name == a.as_str()) { + Some(spec) if spec.takes_value => i += 2, + // An option that takes no value, and an unknown one, which `validate_options` has + // already refused by the time this runs. + Some(_) => i += 1, + None if a.starts_with("--") => i += 1, + None => { + out.push(a.as_str()); + i += 1; + } } - if a.starts_with("--") { - skip = true; - continue; - } - out.push(a.as_str()); } out } @@ -296,7 +350,7 @@ fn read(path: &str) -> Result>, String> { fn info(args: &[String]) -> Result<(), CliError> { validate_options("info", args, INFO_OPTIONS)?; - let path = *positional(args).first().ok_or("info: missing file")?; + let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; @@ -347,7 +401,7 @@ fn info(args: &[String]) -> Result<(), CliError> { fn text(args: &[String]) -> Result<(), CliError> { validate_options("text", args, TEXT_OPTIONS)?; - let path = *positional(args).first().ok_or("text: missing file")?; + let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?; let mut selected_page = None; // Validate every supplied value before reading, retaining the first valid selection. let mut options = args.iter(); @@ -472,7 +526,13 @@ fn edit(args: &[String]) -> Result<(), CliError> { continue; } } - let value = args.get(i + 1).map(String::as_str).ok_or_else(|| format!("edit: {arg} needs a value"))?; + // `edit` parses its own options (they run in order), so `--out` is checked here in the + // same way: it wrote a PDF to a file called `--full`. + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(arg, value) { + return Err(needs_value("edit", arg, value).into()); + } + let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?; match arg { "--out" => out = Some(value), "--password" => password = Some(value), @@ -545,7 +605,7 @@ fn file_stem(path: &str) -> String { fn combine(args: &[String]) -> Result<(), CliError> { validate_options("combine", args, COMBINE_OPTIONS)?; let out = flag(args, "--out").ok_or("combine: missing --out")?; - let inputs = positional(args); + let inputs = positional(args, COMBINE_OPTIONS); if inputs.len() < 2 { return Err("combine: give at least two input files".into()); } @@ -556,7 +616,7 @@ fn combine(args: &[String]) -> Result<(), CliError> { fn extract(args: &[String]) -> Result<(), CliError> { validate_options("extract", args, EXTRACT_OPTIONS)?; - let path = *positional(args).first().ok_or("extract: missing file")?; + let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?; let out = flag(args, "--out").ok_or("extract: missing --out")?; let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?; let mut session = pdfcraft_engine::Session::new(); @@ -568,7 +628,7 @@ fn extract(args: &[String]) -> Result<(), CliError> { fn split(args: &[String]) -> Result<(), CliError> { validate_options("split", args, SPLIT_OPTIONS)?; use pdfcraft_engine::SplitBy; - let path = *positional(args).first().ok_or("split: missing file")?; + let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?; let by = match (flag(args, "--every"), flag(args, "--before")) { (Some(n), None) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?), (None, Some(list)) => SplitBy::Before(page_list(list)?), @@ -592,7 +652,7 @@ fn split(args: &[String]) -> Result<(), CliError> { fn render(args: &[String]) -> Result<(), CliError> { validate_options("render", args, RENDER_OPTIONS)?; - let path = *positional(args).first().ok_or("render: missing file")?; + let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?; let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?; let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?; let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?; @@ -646,7 +706,7 @@ fn render(args: &[String]) -> Result<(), CliError> { /// Child-process body for `check`: prints one JSON line. fn check_one(args: &[String]) -> Result<(), CliError> { validate_options("check-one", args, CHECK_ONE_OPTIONS)?; - let path = *positional(args).first().ok_or("check-one: missing file")?; + let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?; let start = Instant::now(); let bytes = read(path)?; @@ -739,7 +799,7 @@ fn collect(paths: &[&str]) -> Vec { fn check(args: &[String]) -> Result<(), CliError> { validate_options("check", args, CHECK_OPTIONS)?; - let files = collect(&positional(args)); + let files = collect(&positional(args, CHECK_OPTIONS)); if files.is_empty() { return Err("check: no PDF files found".into()); } @@ -924,9 +984,9 @@ fn run(args: &[String]) -> Result<(), CliError> { } return Ok(()); } - let tool = *positional(args).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; + let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; let mut obj = serde_json::Map::new(); - for kv in positional(args).iter().skip(1) { + for kv in positional(args, RUN_OPTIONS).iter().skip(1) { let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?; let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())); obj.insert(k.to_string(), value); @@ -992,7 +1052,7 @@ fn ui(args: &[String]) -> Result<(), CliError> { // Refused rather than truncated, which would quietly reach some other port. let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?; let token = info["token"].as_str().ok_or(format!("{file}: no token"))?; - let pos = positional(args); + let pos = positional(args, UI_OPTIONS); let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?; let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") }; let mut params = serde_json::Map::new(); diff --git a/apps/pdfcraft-cli/tests/options.rs b/apps/pdfcraft-cli/tests/options.rs index 9576d59a8..14b3e617c 100644 --- a/apps/pdfcraft-cli/tests/options.rs +++ b/apps/pdfcraft-cli/tests/options.rs @@ -27,3 +27,84 @@ fn non_edit_commands_reject_unknown_options_before_io() { assert!(stderr.contains("unknown option") && stderr.contains(unknown), "{args:?}: {stderr}"); } } + +/// photocraft#6944: an option took the next token whatever it was, so a line with a value missing +/// in the middle of it did something else entirely — `extract in.pdf --pages 1 --out --password` +/// wrote the PDF to a file called `--password` and exited 0. The unknown option hidden in a value +/// position passed the check above too, since the walk skipped it along with the option. +/// +/// Only the options whose value is a file or folder refuse one: a password or a title can be any +/// string, `--page` included, which `text`'s own tests pin and this does not change. +#[test] +fn a_file_option_refuses_a_value_that_is_another_option() { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-values-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + for (option, args) in [ + ("--out", vec!["extract", "missing.pdf", "--pages", "1", "--out", "--password"]), + ("--out", vec!["combine", "a.pdf", "b.pdf", "--out", "--nonsense"]), + ("--out", vec!["render", "missing.pdf", "--out", "--dpi", "96"]), + ("--out-dir", vec!["split", "missing.pdf", "--every", "2", "--out-dir", "--password"]), + ("--json", vec!["check", "missing.pdf", "--json", "--dpi"]), + ("--control", vec!["ui", "--control", "--out", "state"]), + // `edit` parses its own options, in the order given, and had the same gap. + ("--out", vec!["edit", "missing.pdf", "--out", "--full"]), + ] { + let out = Command::new(BIN).current_dir(&dir).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?} unexpectedly succeeded"); + assert!(stderr.contains(&format!("{option} needs a value")), "{args:?}: {stderr}"); + assert!(stderr.contains("another option"), "{args:?}: {stderr}"); + } + assert_eq!(std::fs::read_dir(&dir).expect("read dir").count(), 0, "nothing was written"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// And a file whose name really does start with `-` is still reachable, the way the shell reaches +/// one and `--root` already documented: `./--name`. The error is then about the input file, which +/// is as far as these lines get here. +#[test] +fn a_file_whose_name_starts_with_dashes_is_still_reachable() { + for args in [ + vec!["extract", "missing.pdf", "--pages", "1", "--out", "./--dash.pdf"], + vec!["edit", "missing.pdf", "--out", "./--dash.pdf"], + vec!["info", "./--dash.pdf"], + ] { + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?}: no such file either way"); + assert!(stderr.contains("No such file"), "{args:?}: {stderr}"); + assert!(!stderr.contains("needs a value"), "{args:?}: {stderr}"); + } +} + +/// The token after an option that takes no value is not its value: `check-one --edit file.pdf` +/// reported "check-one: missing file", the file having been read as `--edit`'s operand. +#[test] +fn a_flag_that_takes_no_value_does_not_swallow_the_file() { + for args in [vec!["check-one", "--edit", "missing.pdf"], vec!["check-one", "missing.pdf", "--edit"]] { + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?}"); + assert!(stderr.contains("missing.pdf") && stderr.contains("No such file"), "{args:?}: {stderr}"); + assert!(!stderr.contains("missing file"), "the file was found, and then not found: {stderr}"); + } +} + +/// photocraft#1108: an argument that is not UTF-8 panicked in `std::env::args` ("called +/// `Result::unwrap()` on an `Err` value"), while collecting the arguments — so it panicked even +/// for `--help`. Such a name cannot be used here, where paths are `str`, so it is a usage error. +#[cfg(unix)] +#[test] +fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + for args in [vec![OsStr::new("info"), OsStr::from_bytes(b"/tmp/caf\xe9.pdf")], vec![OsStr::new("--help"), OsStr::from_bytes(b"caf\xe9")]] { + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "an error, not a panic (101): {stderr}"); + assert!(stderr.contains("not valid UTF-8"), "{stderr}"); + assert!(!stderr.contains("panicked"), "{stderr}"); + } +} From b6b67a86d834c74230563a1d309cc6a3826bfbb9 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 22 Oct 2026 15:08:56 +0800 Subject: [PATCH 04/59] fix(automation): guard MCP dispatch and bound line and file reads (#3924) --- crates/automation/src/a11y.rs | 10 +- crates/automation/src/comments.rs | 4 +- crates/automation/src/content.rs | 4 +- crates/automation/src/file_access.rs | 6 +- crates/automation/src/forms.rs | 2 +- crates/automation/src/lib.rs | 75 +++++++++++-- crates/automation/src/mcp.rs | 154 +++++++++++++++++++++++++-- crates/automation/src/signing.rs | 4 +- 8 files changed, 225 insertions(+), 34 deletions(-) diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 212454973..423e6d251 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -607,9 +607,9 @@ impl Automation { for p in paths { let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); let result = - self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())).and_then(|bytes| { - pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true) - }); + self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then( + |bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true), + ); out.push(match result { Ok(r) => { let target = child(&folder, &name); @@ -696,7 +696,7 @@ impl Automation { let mut files = Vec::new(); for p in a.strs("paths")? { let path = self.resolve(p, false)?; - files.push((p.to_string(), std::fs::read(&path).map_err(|e| failed(format!("{p}: {e}")))?)); + files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?)); } let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?; let target = self.resolve(a.str("path")?, true)?; @@ -841,7 +841,7 @@ impl Automation { let result = self .resolve(p, false) .map_err(|e| e.to_string()) - .and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())) + .and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())) .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); out.push(match result { Ok(r) => { diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 3c53563be..1266db010 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -385,7 +385,7 @@ impl Automation { "attachment" => { let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?; let path = self.resolve(a.str("path")?, false)?; - let data = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let data = crate::read_input(&path)?; let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let icon = match a.opt_str("icon")? { Some(n) => pdfcraft_engine::AttachIcon::from_name(n) @@ -679,7 +679,7 @@ impl Automation { let page = self.page(a)?; let info = self.doc(a)?.info.pages[page].clone(); let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned())); let [x, y] = a.need::<2>("at", "a stamp (its centre)")?; diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 8926a1368..a69f56517 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -118,7 +118,7 @@ impl Automation { let page = self.page(a)?; let ph = self.doc(a)?.info.pages[page].height as f64; let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let rect = rect_arg(a, "rect")?.map(|r| to_display(r, ph)); let mut out = self.apply(a, Edit::AddImage { page, rect, name, bytes: std::sync::Arc::new(bytes) })?; @@ -188,7 +188,7 @@ impl Automation { } if let Some(p) = a.opt_str("image")? { let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); // Box and transforms first, then the new picture. self.apply(a, Edit::UpdateContent { page, index, content: content.clone() })?; diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs index 55b10662f..4820743e2 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -345,7 +345,8 @@ mod tests { // Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its // inputs and out_dir, walks only inside them (skipping links) and resolves every // discovered file again before `process` reads it or `publish` writes it. - const RESOLVED_BY_CALLER: &[&str] = &["write_atomic", "write_atomic_with", "read_review_sidecar", "process", "linked", "walk", "publish"]; + const RESOLVED_BY_CALLER: &[&str] = + &["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"]; let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut offenders = vec![]; for entry in std::fs::read_dir(&dir).unwrap() { @@ -365,7 +366,8 @@ mod tests { || !(trimmed.contains("std::fs") || trimmed.contains("File::open") || trimmed.contains("File::create") - || trimmed.contains("OpenOptions")) + || trimmed.contains("OpenOptions") + || trimmed.contains("read_input")) { continue; } diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 26e7b9d06..00e430b30 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -684,7 +684,7 @@ impl Automation { pub(crate) fn doc_import_data(&mut self, a: &Args) -> Result { let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let before = (self.doc(a)?.info.annotations.len(), self.doc(a)?.form.iter().filter(|f| !f.value.is_empty()).count()); let mut out = self.apply(a, Edit::ImportData { name, bytes: std::sync::Arc::new(bytes) })?; diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index c90240875..f5c08ce10 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -394,7 +394,7 @@ impl Automation { "page_replace" => { let pages = self.pages(&a, "pages")?; let path = self.resolve(a.str("path")?, false)?; - let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?); + let bytes = Arc::new(read_input(&path)?); let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let src_pages = match a.opt_ints("from_pages")? { Some(p) => one_based(&p)?, @@ -543,7 +543,7 @@ impl Automation { "delete" => ImageEdit::Delete, "replace" => { let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; ImageEdit::Replace { name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes: Arc::new(bytes), @@ -766,7 +766,7 @@ impl Automation { "form_fill" => self.form_fill(&a)?, "form_set_image" => { let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? } "form_reset" => self.form_reset(&a)?, @@ -1000,7 +1000,7 @@ impl Automation { fn doc_open(&mut self, a: &Args) -> Result { let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; @@ -1270,7 +1270,7 @@ impl Automation { fn mark_file(&self, a: &Args) -> Result> { let Some(p) = a.opt_str("file")? else { return Ok(None) }; let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1; Ok(Some(pdfcraft_engine::MarkFile { name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), @@ -1444,7 +1444,7 @@ impl Automation { let mut images = Vec::new(); for p in a.strs("paths")? { let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes)); } let name = if images.len() == 1 { @@ -1459,7 +1459,7 @@ impl Automation { (Some(t), _) => ("Text".to_string(), t.to_string()), (None, Some(p)) => { let path = self.resolve(p, false)?; - let t = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let t = read_input(&path)?; (path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t)) } (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), @@ -1634,7 +1634,7 @@ impl Automation { fn insert_file(&mut self, a: &Args) -> Result { let at = self.position(a, "at")?; let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename }) @@ -1677,7 +1677,7 @@ impl Automation { // Each file is read, converted and copied in turn, so a long list holds one file at a time. let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result { let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?; let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); @@ -1694,7 +1694,7 @@ impl Automation { let mut out = Vec::new(); for &p in paths { let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| { - let bytes = std::fs::read(&src).map_err(|e| format!("{}: {e}", src.display()))?; + let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?; let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?; @@ -1798,7 +1798,7 @@ impl Automation { if let Some(g) = group { firsts.push((g, path.clone(), sources.len())); } - let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?); + let bytes = Arc::new(read_input(&path)?); // Images and text files are made into PDFs first (#569), as in the Combine files tab. let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); self.session.as_pdf(&file_name, &bytes).map_err(failed)? @@ -2700,6 +2700,28 @@ fn unused(dir: &Path, stem: &str) -> PathBuf { (2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first) } +/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`). +pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024; + +/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap. +pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result> { + use std::io::Read as _; + let file = std::fs::File::open(path)?; + if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + let mut bytes = Vec::new(); + file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_INPUT { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + Ok(bytes) +} + +pub(crate) fn read_input(path: &Path) -> Result> { + read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display()))) +} + /// Write and sync a temporary file in the same directory, then rename over the target. /// This flushes staged contents; it does not promise parent-directory durability after a /// power loss. Filesystems that refuse sync cause the save to fail before publication. @@ -3257,4 +3279,35 @@ mod tests { assert!(a.session.get(id).is_none()); } } + + #[test] + fn tool_file_reads_refuse_inputs_exceeding_512_mib() { + let dir = staging_dir("capped-read"); + let huge = dir.join("huge.bin"); + let file = std::fs::File::create(&huge).unwrap(); + file.set_len(MAX_INPUT + 1).unwrap(); + drop(file); + let huge_str = huge.to_string_lossy().into_owned(); + + let mut a = Automation::new(); + let id = a.session.open("a.pdf", None, Arc::new(numbered_pages(2)), None).unwrap().0; + for (tool, args) in [ + ("doc_open", json!({ "path": huge_str })), + ("page_insert_file", json!({ "doc": id, "at": 1, "path": huge_str })), + ("page_replace", json!({ "doc": id, "pages": [1], "path": huge_str })), + ("page_add_image", json!({ "doc": id, "page": 1, "path": huge_str })), + ("doc_import_data", json!({ "doc": id, "path": huge_str })), + ("doc_create", json!({ "from": "text", "path": huge_str })), + ("doc_create", json!({ "from": "images", "paths": [huge_str] })), + ("doc_combine", json!({ "paths": [huge_str, huge_str] })), + ("form_merge_data", json!({ "paths": [huge_str], "path": dir.join("out.csv").to_string_lossy() })), + ("comment_add", json!({ "doc": id, "page": 1, "type": "attachment", "at": [72.0, 72.0], "path": huge_str })), + ("stamp_custom", json!({ "doc": id, "page": 1, "at": [72.0, 72.0], "path": huge_str })), + ("sign_trust", json!({ "paths": [huge_str] })), + ] { + let err = a.call(tool, &args).unwrap_err().to_string(); + assert!(err.contains("input exceeds 512 MiB"), "{tool}: {err}"); + } + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index f5fda5be9..84cc21b25 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -25,6 +25,10 @@ const PARSE_ERROR: i64 = -32700; const INVALID_REQUEST: i64 = -32600; const METHOD_NOT_FOUND: i64 = -32601; const INVALID_PARAMS: i64 = -32602; +const INTERNAL_ERROR: i64 = -32603; + +/// Longest JSON-RPC request line [`McpServer::serve`] buffers into memory (16 MiB). +pub const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; const INSTRUCTIONS: &str = "PdfCraft edits PDFs. Open a file with doc_open to get a document id, then inspect \ (doc_info, text_extract, text_find, page_render) or edit it (page_*, doc_set_info). Edits are undoable \ @@ -83,18 +87,36 @@ impl McpServer { } /// Serve until `input` reaches end of file. - pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { - for line in input.lines() { - let line = line?; - if line.trim().is_empty() { - continue; + pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + let mut buf = Vec::new(); + loop { + match read_line_limited(&mut input, &mut buf, MAX_LINE_BYTES)? { + LineRead::End => return Ok(()), + LineRead::TooLong => { + drain_line(&mut input)?; + buf.clear(); + buf.shrink_to(64 * 1024); + let reply = error(Value::Null, INVALID_REQUEST, &format!("request line exceeds {MAX_LINE_BYTES} bytes")); + writeln!(output, "{reply}")?; + output.flush()?; + continue; + } + LineRead::Line => {} } - if let Some(reply) = self.handle_line(&line) { + let reply = match std::str::from_utf8(&buf) { + Ok(line) if line.trim().is_empty() => None, + Ok(line) => self.handle_line(line), + Err(e) => Some(error(Value::Null, PARSE_ERROR, &format!("parse error: {e}")).to_string()), + }; + if buf.capacity() > 64 * 1024 { + buf.clear(); + buf.shrink_to(64 * 1024); + } + if let Some(reply) = reply { writeln!(output, "{reply}")?; output.flush()?; } } - Ok(()) } /// Handle one JSON-RPC message; returns the serialized reply, if one is due. @@ -116,7 +138,7 @@ impl McpServer { }; let params = obj.get("params").cloned().unwrap_or(Value::Null); let id = id?; // notifications/initialized, notifications/cancelled, …: nothing to answer - let result = if method == "tools/call" { guarded_tool(|| self.dispatch(method, ¶ms)) } else { self.dispatch(method, ¶ms) }; + let result = guarded_dispatch(method, || self.dispatch(method, ¶ms)); Some(match result { Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), Err((code, message)) => error(id, code, &message), @@ -323,11 +345,67 @@ fn unwrap_tool_call(args: &Value) -> Result<(String, Value), String> { Ok((name.to_owned(), inner)) } +/// What [`read_line_limited`] found. +#[derive(Debug, PartialEq, Eq)] +enum LineRead { + /// A line, now in the buffer (newline and a trailing `\r` stripped). + Line, + /// End of stream. + End, + /// The line is longer than the limit; at most `max + 1` bytes of it were read. + TooLong, +} + +/// Read one `\n`-terminated line of at most `max` bytes into `buf`. +fn read_line_limited(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> std::io::Result { + use std::io::Read as _; + buf.clear(); + // `take` bounds what is buffered for one line: never more than `max + 1` bytes. + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + if reader.take(limit).read_until(b'\n', buf)? == 0 { + return Ok(LineRead::End); + } + if buf.last() == Some(&b'\n') { + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + return Ok(LineRead::Line); + } + // No newline: either the limit was passed, or this is a last line before end of stream. + Ok(if buf.len() > max { LineRead::TooLong } else { LineRead::Line }) +} + +/// Discard the rest of an overlong line up to `\n` or end of stream without buffering it. +fn drain_line(reader: &mut impl BufRead) -> std::io::Result<()> { + loop { + let chunk = reader.fill_buf()?; + if chunk.is_empty() { + return Ok(()); + } + if let Some(pos) = chunk.iter().position(|&b| b == b'\n') { + reader.consume(pos + 1); + return Ok(()); + } + let len = chunk.len(); + reader.consume(len); + } +} + /// Last-resort boundary shared by every tools/call route, including compact dispatch. fn guarded_tool(f: impl FnOnce() -> Result) -> Result { pdfcraft_engine::guard(f).unwrap_or_else(|message| Ok(tool_error(&format!("internal error: {message}")))) } +/// Last-resort boundary for every JSON-RPC method: tool calls report panics in the tool result +/// (`isError: true`), while resource and protocol methods report a JSON-RPC internal error. +fn guarded_dispatch(method: &str, f: impl FnOnce() -> Result) -> Result { + if method == "tools/call" { + return guarded_tool(f); + } + pdfcraft_engine::guard(f).unwrap_or_else(|message| Err((INTERNAL_ERROR, format!("internal error: {message}")))) +} + fn tool_content(name: &str, content: Vec) -> Value { let failed = matches!(name, "command_batch" | "doc_batch") && content.iter().any(|c| match c { @@ -509,7 +587,12 @@ fn call_result(content: Vec) -> Value { #[cfg(test)] mod tests { - use super::{Resource, first_sentence, parse_uri}; + use std::io::{BufReader, Read as _}; + + use serde_json::{Value, json}; + + use super::{McpServer, Resource, first_sentence, parse_uri}; + use crate::Automation; #[test] fn conventions_panic_is_a_tool_error() { @@ -520,6 +603,59 @@ mod tests { assert_eq!(next["ok"], true); } + #[test] + fn guarded_dispatch_catches_resource_and_protocol_panics() { + for method in ["resources/read", "resources/list", "resources/templates/list", "initialize"] { + let (code, msg) = super::guarded_dispatch(method, || panic!("synthetic {method} failure")).unwrap_err(); + assert_eq!(code, -32603, "{method}"); + assert!(msg.contains(&format!("synthetic {method} failure")), "{msg}"); + } + let ok = super::guarded_dispatch("resources/list", || Ok(json!({"resources": []}))).unwrap(); + assert_eq!(ok["resources"], json!([])); + } + + #[test] + fn read_line_limited_and_drain_line_respect_boundaries() { + use super::{LineRead, drain_line, read_line_limited}; + let mut buf = Vec::new(); + let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc"; + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"bb", "CRLF is stripped"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"cc", "a last line without a newline is still read"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::End); + + let mut overlong: &[u8] = b"0123456789abcdef\nafter\n"; + assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::TooLong); + assert_eq!(buf.len(), 11, "at most max + 1 bytes are buffered"); + drain_line(&mut overlong).unwrap(); + assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"after", "the next line after an overlong line is read cleanly"); + } + + #[test] + fn serve_answers_non_utf8_and_overlong_lines_and_keeps_serving() { + let init = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } } + }) + .to_string(); + let tail = format!("\n{init}\n"); + let stream = (&b"\xff\n"[..]).chain(std::io::repeat(b'a').take(64 * 1024 * 1024)).chain(tail.as_bytes()); + let mut out = Vec::new(); + McpServer::new(Automation::new()).serve(BufReader::new(stream), &mut out).unwrap(); + let replies: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.len(), 3, "{replies:?}"); + assert_eq!(replies[0]["error"]["code"], -32700); + assert_eq!(replies[1]["error"]["code"], -32600); + assert_eq!(replies[2]["id"], 1); + assert_eq!(replies[2]["result"]["serverInfo"]["name"], "pdfcraft"); + } + #[test] fn first_sentence_skips_abbreviations() { assert_eq!(first_sentence("Rotate pages. Undoable."), "Rotate pages."); diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index c7a043c89..743a36ff5 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -189,7 +189,7 @@ impl Automation { return Err(failed(format!("windows:{r}: Windows certificate store identities are only available on Windows"))); } let path = self.resolve(a.str("id")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(|e| match e { sign::SignError::WrongPassword => bad("the digital ID password is incorrect"), e => failed(e), @@ -320,7 +320,7 @@ impl Automation { }; for p in paths { let path = self.resolve(&p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let lower = p.to_ascii_lowercase(); let found = if lower.ends_with(".p12") || lower.ends_with(".pfx") { let id = sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(failed)?; From 356e6cefe04d6919cc948e462464d392052c224d Mon Sep 17 00:00:00 2001 From: Adrien Alberti Date: Thu, 22 Oct 2026 11:52:49 +0200 Subject: [PATCH 05/59] M12: type or pick an architect's 1:N drawing scale in the Measure panel, applied at once On top of #3011's page unit, the Drawing scale block gets a "1:N" field (1:50, 1/100, 50, 1:2,5 are read; zero, negative, huge and malformed values are refused with a message) and a combo of common architect scales (1:1 to 1:1000). Either one fills the existing fields (1 mm of paper = N mm, shown in metres) and applies the scale to the page without a second click, only when the document allows annotation. Apply scale is unchanged. --- crates/ui-egui/src/measure_ui.rs | 142 ++++++++++++++++++++++++++++++- crates/ui-egui/tests/control.rs | 52 +++++++++++ parity/acrobat-features.toml | 2 +- 3 files changed, 193 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index f91e4bf3f..d80ecca3e 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -55,6 +55,10 @@ pub struct MeasureView { pub unit: String, /// The unit picked for the page side of the scale; `None` follows `unit`. pub page_unit: Option, + /// The "1:N" field of the drawing scale, as typed. + pub ratio_text: String, + /// The "1:N" field was edited and not yet applied. + ratio_dirty: bool, pub precision: u8, pub name: String, pub whole_page: bool, @@ -83,6 +87,8 @@ impl Default for MeasureView { real_distance: 1.0, unit: "in".into(), page_unit: None, + ratio_text: String::new(), + ratio_dirty: false, precision: 2, name: "Drawing scale".into(), whole_page: true, @@ -99,6 +105,35 @@ impl Default for MeasureView { pub(crate) fn page_unit_of(picked: Option, unit: &str, display: crate::units::Unit) -> PageUnit { picked.or_else(|| PageUnit::from_label(unit)).or_else(|| PageUnit::from_label(display.label())).unwrap_or(PageUnit::Pt) } +/// Drawing scales an architect's rule offers, as the N of 1:N. +pub(crate) const RATIOS: [f64; 10] = [1.0, 5.0, 10.0, 20.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0]; +/// "1:200", "1/200", "1 : 200" or a bare "200" (a comma works as the decimal mark), as the N of 1:N. +/// `None` for anything else, for zero, negative or absurd values. +pub(crate) fn parse_ratio(text: &str) -> Option { + let text = text.trim().replace(',', "."); + let n = match text.split_once([':', '/']) { + Some((one, n)) => n.trim().parse::().ok()? / one.trim().parse::().ok()?, + None => text.parse::().ok()?, + }; + (n.is_finite() && (1e-6..=1e9).contains(&n)).then_some(n) +} +/// "1:200", or "1:2.5" for a ratio that isn't a whole number. +pub(crate) fn format_ratio(n: f64) -> String { + let rounded = (n * 100.0).round() / 100.0; + if (rounded - rounded.round()).abs() < 1e-9 { format!("1:{}", rounded.round()) } else { format!("1:{rounded}") } +} +/// Fill the scale fields for 1:N: 1 mm on the page is N mm, shown in metres. Returns `false`, changing nothing, when `n` is not a usable ratio. +pub(crate) fn set_ratio(state: &mut MeasureView, n: f64) -> bool { + if !n.is_finite() || n <= 0.0 { + return false; + } + state.unit = "m".into(); + state.page_unit = Some(PageUnit::Mm); + state.drawing_points = 1.0; + state.real_distance = n / 1000.0; + state.ratio_text = format_ratio(n); + true +} impl MeasureView { pub fn cancel(&mut self) { self.points.clear(); @@ -363,7 +398,27 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.add(egui::Slider::new(&mut state.sensitivity, 1.0..=20.0).text(tl!("Sensitivity"))); } ui.separator(); - ui.label(tl!("Drawing scale")); + // "1:N" fills the fields below and applies at once, as does picking a common scale. + let mut chosen = None; + ui.horizontal(|ui| { + ui.label(tl!("Drawing scale")); + let typed = ui.add(crate::widgets::line(&mut state.ratio_text).hint_text("1:100").desired_width(56.0).char_limit(16)); + typed.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tl!("Scale"))); + state.ratio_dirty |= typed.changed(); + if typed.lost_focus() && std::mem::take(&mut state.ratio_dirty) { + match parse_ratio(&state.ratio_text) { + Some(n) => chosen = Some(n), + None => state.error = Some(tl!("Enter a scale such as 1:50.").into()), + } + } + egui::ComboBox::from_id_salt("measure-common-scales").selected_text(tl!("Common scales")).width(20.0).show_ui(ui, |ui| { + for n in RATIOS { + if ui.selectable_label(state.ratio_text == format_ratio(n), format_ratio(n)).clicked() { + chosen = Some(n); + } + } + }); + }); ui.horizontal(|ui| { ui.add(egui::DragValue::new(&mut state.drawing_points).range(0.000001..=1e9)); let page_unit = page_unit_of(state.page_unit, &state.unit, units); @@ -427,7 +482,16 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { } } let export = ui.button(tl!("Export measurements as CSV")).clicked(); - if apply && let Some(info) = info { + if let Some(n) = chosen + && set_ratio(state, n) + { + state.error = None; + } + // Apply scale, or a 1:N typed or picked: the fields are applied to the document at once. + if (apply || chosen.is_some()) + && doc.allows_annotation() + && let Some(info) = info + { let result = doc.measurement_to_user(page, [0.0, 0.0]).and_then(|a| doc.measurement_to_user(page, [1.0, 0.0]).map(|b| (a, b))).and_then(|(a, b)| { let page_unit = page_unit_of(state.page_unit, &state.unit, units); @@ -513,3 +577,77 @@ fn export_csv(app: &mut PdfCraftApp) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// What Apply scale builds from the panel's fields, then a distance read with it. + fn measured(state: &MeasureView, from: Point, to: Point) -> f64 { + let page_unit = page_unit_of(state.page_unit, &state.unit, crate::units::Unit::Points); + let scale = Scale::ratio([0.0, 0.0], [1.0, 0.0], state.drawing_points, page_unit, state.real_distance, &state.unit, 4).expect("scale"); + measure::reading(Kind::Distance, &[from, to], &scale).expect("reading").value + } + + #[test] + fn typed_ratios_are_read_leniently() { + assert_eq!(parse_ratio("1:200"), Some(200.0)); + assert_eq!(parse_ratio(" 1 : 50 "), Some(50.0)); + assert_eq!(parse_ratio("1/20"), Some(20.0)); + assert_eq!(parse_ratio("250"), Some(250.0)); + assert_eq!(parse_ratio("1:2,5"), Some(2.5)); + assert_eq!(parse_ratio("2:1"), Some(0.5)); + } + + #[test] + fn invalid_ratios_are_refused() { + for bad in ["", " ", ":", "1:", ":50", "1:0", "0", "-5", "1:-5", "0:5", "abc", "1:abc", "1:50:2", "1:inf", "NaN", "1e12", "1:1e12"] { + assert_eq!(parse_ratio(bad), None, "{bad:?}"); + } + } + + #[test] + fn ratios_are_written_as_people_say_them() { + assert_eq!(format_ratio(50.0), "1:50"); + assert_eq!(format_ratio(2.5), "1:2.5"); + assert_eq!(format_ratio(0.5), "1:0.5"); + for n in RATIOS { + assert_eq!(parse_ratio(&format_ratio(n)), Some(n)); + } + } + + #[test] + fn every_offered_ratio_reads_paper_length_times_n_in_metres_whatever_the_unit_was() { + for unit in ["mm", "cm", "m", "km", "in", "ft"] { + for n in RATIOS { + let mut state = MeasureView { unit: unit.into(), ..Default::default() }; + assert!(set_ratio(&mut state, n)); + assert_eq!(state.unit, "m"); + // 72 pt is one inch of paper: 25.4 mm x N, shown in metres. + let v = measured(&state, [0.0, 0.0], [0.0, 72.0]); + let expected = 25.4 * n / 1000.0; + assert!((v - expected).abs() < 1e-9 * expected.max(1.0), "1:{n} in {unit} gave {v}, wanted {expected}"); + } + } + } + + #[test] + fn any_starting_unit_becomes_metres() { + let mut state = MeasureView { unit: "yards?".into(), ..Default::default() }; + assert!(set_ratio(&mut state, 50.0)); + assert_eq!(state.unit, "m"); + // 1 mm of paper at 1:50 is 50 mm. + let v = measured(&state, [0.0, 0.0], [72.0 / 25.4, 0.0]); + assert!((v - 0.05).abs() < 1e-9, "{v}"); + } + + #[test] + fn an_unusable_ratio_changes_nothing() { + let mut state = MeasureView::default(); + let before = (state.drawing_points, state.real_distance, state.unit.clone(), state.page_unit); + for n in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert!(!set_ratio(&mut state, n)); + } + assert_eq!((state.drawing_points, state.real_distance, state.unit.clone(), state.page_unit), before); + } +} diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index 9082c689c..516d85ed6 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -1362,6 +1362,58 @@ fn a_1_to_50_scale_in_millimetres_measures_a_10_mm_line_as_500_mm() { assert_eq!(measurements[0].reading.unit, "mm"); } +#[test] +fn a_plan_scale_picked_or_typed_applies_at_once() { + use pdfcraft_engine::measure::{self, Kind}; + let (mut h, c) = harness_pages(1); + let doc = h.state().views[0].id; + ok(&mut h, &c, "ui.command", json!({"id":"measure.scale"})); + h.run_steps(3); + h.state_mut().views[0].measure.unit = "m".into(); + h.run_steps(2); + // 60 mm of paper. + let line = [[20.0, 20.0], [20.0 + 60.0 / 25.4 * 72.0, 20.0]]; + let read = |h: &Harness<'static, PdfCraftApp>| { + let scale = h.state().session.get(doc).unwrap().measurement_scale(0, line[0]).unwrap(); + (measure::reading(Kind::Distance, &line, &scale).unwrap().label, scale.ratio) + }; + // Picked from the common scales: no Apply click. + h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().is_some_and(|v| v == "Common scales")).click(); + h.run_steps(2); + h.get_by_label("1:200").click_accesskit(); + h.run_steps(3); + assert_eq!(read(&h), ("12.00 m".to_string(), "1 mm = 0.2 m".to_string())); + // Typed, in the other notations; leaving the field applies. + fn field<'a>(h: &'a Harness<'static, PdfCraftApp>) -> egui_kittest::Node<'a> { + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Scale") + } + for (typed, label) in [("1/50", "3.00 m"), ("1:100", "6.00 m")] { + field(&h).click(); + h.run_steps(1); + h.state_mut().views[0].measure.ratio_text.clear(); + field(&h).type_text(typed); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(read(&h).0, label, "{typed}"); + } + // Nonsense is refused with a message and leaves the page scale alone. + let before = read(&h); + field(&h).click(); + h.run_steps(1); + h.state_mut().views[0].measure.ratio_text.clear(); + field(&h).type_text("1:0"); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(read(&h), before); + assert!(h.state().views[0].measure.error.is_some()); + // The scale is one undoable edit. + ok(&mut h, &c, "ui.command", json!({"id":"edit.undo"})); + h.run_steps(2); + assert_ne!(read(&h), before); +} + #[test] fn docking_pointer_group_float_close_restore_and_document_commands() { use pdfcraft_ui_egui::docking::Panel; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c06f26e24..423d1814b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4256,7 +4256,7 @@ milestone = "M12" status = "partial" commands = ["measure.distance"] tools = ["measure_distance"] -evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] +evidence = ["crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/control.rs::measurement_tools_draw_live_calibrate_save_and_export", "crates/ui-egui/tests/control.rs::a_plan_scale_picked_or_typed_applies_at_once", "crates/ui-egui/src/measure_ui.rs::typed_ratios_are_read_leniently"] notes = "Two-point distance annotations with saved numeric captions and rectilinear scales. Partial: only single-unit decimal formats; compound (ft-in) and fractional formats are not supported (listed as unsupported, data kept), no leader lines or caption placement options, geospatial scales unsupported, and interop with Acrobat-authored measurements (reading theirs, Acrobat reading ours) is untested." [[feature]] From 62c595ceba2f93e1708a1f5e7e358dae988d3ba5 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:06:00 +0800 Subject: [PATCH 06/59] M3: a fresh install follows the desktop's appearance (#1248) The theme preference defaults to System: dark on a dark desktop, light where the desktop says nothing. Saved settings and --theme still win. Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 2 ++ crates/ui-egui/src/lib.rs | 3 ++- crates/ui-egui/src/theme.rs | 5 +++-- crates/ui-egui/tests/it/theme.rs | 23 ++++++++++++++++++++++- parity/acrobat-features.toml | 4 ++-- 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bc057209c..dc7f544d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -343,6 +343,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (community, M14, Polish signature panel):** A Polish signature panel listed its details in English: the catalog lacked every signature wording, the changes after signing they name and 34 UI literals. All 101 are translated; `polish_covers_ui_literals` and `polish_says_what_a_signature_says` keep them so. Estimates unchanged. - **2026-10-14 (M14, Finnish localization):** Added Finnish as another interface and date language, with a 2,674-entry catalog covering every key of the other bundled catalogs and two-form plurals (partitive after numbers other than 1). Calendar dates use partitive month names (`14. lokakuuta 2026`). Coverage tests check commands/tools, scanned UI literals and bundled catalog keys. AI-assisted; native proofreading pending. - **2026-10-14 (M14, Catalan localization):** Added Catalan as another interface and date language, with a 2,592-entry clean-room catalog, one/other plurals and coverage tests for commands, tools, UI literals and every key in the other catalogs. A native Catalan speaker (the contributor) has reviewed the translation. Estimates unchanged. + +- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732. - **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-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64b22ff18..505f3e6a0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -957,7 +957,8 @@ impl PdfCraftApp { full_screen_tool: None, comment_prefs: Default::default(), theme: ThemeKind::Light, - theme_preference: ThemePreference::Light, + // A fresh install follows the desktop's appearance (#1248); saved settings override. + theme_preference: ThemePreference::System, reading_colors: Default::default(), #[cfg(target_os = "linux")] desktop_theme: None, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 2cd232425..5adb41696 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -13,11 +13,12 @@ pub enum ThemeKind { Dark, } -/// The saved user choice, independent of the light/dark colours currently displayed. +/// The saved user choice, independent of the light/dark colours currently displayed. A fresh +/// install follows the system (#1248); a desktop that says nothing gets Light. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] pub enum ThemePreference { - System, #[default] + System, Light, Dark, } diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index 6e86c5099..11d6a94df 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -48,10 +48,31 @@ fn open_view_menu(h: &mut Harness<'static, PdfCraftApp>) { } } +/// A fresh install follows the desktop's appearance (#1248): dark on a dark desktop, light where +/// the desktop says nothing; a saved choice still wins. +#[test] +fn a_fresh_install_follows_the_system_theme() { + let h = harness(); + assert_theme(&h, ThemePreference::System, ThemeKind::Dark); + let mut quiet = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); + quiet.run_steps(4); + assert_theme(&quiet, ThemePreference::System, ThemeKind::Light); + let mut app = PdfCraftApp::new(); + app.restore(r#"{"theme":"Light"}"#); + assert_eq!(app.theme_preference, ThemePreference::Light, "a saved choice wins over the default"); +} + #[test] fn toolbar_and_view_menu_share_three_state_preferences() { let mut h = harness(); - // Explicit light wins even on a dark desktop, including egui's popup style. + // Fresh, on a dark desktop: System, shown dark. One click on, explicit light wins even on a + // dark desktop, including egui's popup style. + assert_theme(&h, ThemePreference::System, ThemeKind::Dark); + click_toolbar(&mut h, "Use system setting"); assert_theme(&h, ThemePreference::Light, ThemeKind::Light); click_toolbar(&mut h, "Light gray"); assert_theme(&h, ThemePreference::Dark, ThemeKind::Dark); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa77..081dcf7e5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1670,8 +1670,8 @@ milestone = "M3" status = "shipped" commands = ["view.theme.system"] tools = [] -evidence = ["crates/ui-egui/tests/it/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/tests/it/theme.rs::saved_preferences_restore_with_legacy_light_dark_settings", "crates/ui-egui/tests/it/control.rs::theme_commands_and_options_report_preference_and_effective_colours"] -notes = "Toolbar, Display theme and --theme system share a persisted System preference that follows OS changes without losing the selected mode; ui.state reports preference and resolved colours separately." +evidence = ["crates/ui-egui/tests/it/theme.rs::a_fresh_install_follows_the_system_theme", "crates/ui-egui/tests/it/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/tests/it/theme.rs::saved_preferences_restore_with_legacy_light_dark_settings", "crates/ui-egui/tests/it/control.rs::theme_commands_and_options_report_preference_and_effective_colours"] +notes = "Toolbar, Display theme and --theme system share a persisted System preference that follows OS changes without losing the selected mode; ui.state reports preference and resolved colours separately. System is the default on a fresh install (#1248), Light where the desktop says nothing; a saved choice wins." [[feature]] id = "view.home-view" From 7e94c245325eb71da349f9a5649b8a5d53d1245c Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:59:51 +0800 Subject: [PATCH 07/59] M3: the theme button test starts on System (#1248) The click-cycle test assumed a fresh install starts Light; it starts on System now, so the cycle is System, Light, Dark. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/tests/it/theme.rs | 7 ++++--- crates/ui-egui/tests/it/theme_button.rs | 10 +++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index 11d6a94df..1559838dd 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -237,12 +237,13 @@ fn preferences_theme_cards_and_use_system_setting_stay_in_sync() { combo.value().unwrap_or_default() } let mut h = harness(); - h.get_by_label("Display theme: Light gray").click_secondary(); + h.get_by_label("Display theme: Use system setting").click_secondary(); h.run_steps(4); h.get_by_label("Appearance settings…").click(); h.run_steps(10); - assert_theme(&h, ThemePreference::Light, ThemeKind::Light); - assert_eq!(mode_shown(&h), "Light"); + // A fresh install follows the OS (#1248), which is dark here. + assert_theme(&h, ThemePreference::System, ThemeKind::Dark); + assert_eq!(mode_shown(&h), "Use system setting"); // The OS is dark: the Dark card's radio row picks Dark, and the combo box says so. // The cards sit below the fold of the Preferences scroll area at this window size. diff --git a/crates/ui-egui/tests/it/theme_button.rs b/crates/ui-egui/tests/it/theme_button.rs index 14f34086c..758aa1a27 100644 --- a/crates/ui-egui/tests/it/theme_button.rs +++ b/crates/ui-egui/tests/it/theme_button.rs @@ -1,5 +1,5 @@ -//! The title bar's theme button: a click cycles System, Light and Dark; a right-click opens the -//! menu with the appearance settings. +//! The title bar's theme button: a click cycles System, Light and Dark (a fresh install starts +//! on System, #1248); a right-click opens the menu with the appearance settings. use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -22,9 +22,9 @@ fn preference(h: &Harness<'static, PdfCraftApp>) -> String { #[test] fn a_click_cycles_the_theme_and_a_right_click_opens_the_menu() { let mut h = harness(); - assert_eq!(preference(&h), "Light", "new installs start Light"); + assert_eq!(preference(&h), "System", "a fresh install follows the desktop (#1248)"); for (label, next) in - [("Display theme: Light gray", "Dark"), ("Display theme: Dark gray", "System"), ("Display theme: Use system setting", "Light")] + [("Display theme: Use system setting", "Light"), ("Display theme: Light gray", "Dark"), ("Display theme: Dark gray", "System")] { h.get_by_label(label).click(); h.run_steps(4); @@ -32,7 +32,7 @@ fn a_click_cycles_the_theme_and_a_right_click_opens_the_menu() { } assert!(h.query_by_label("Appearance settings…").is_none(), "a plain click opens no menu"); - h.get_by_label("Display theme: Light gray").click_secondary(); + h.get_by_label("Display theme: Use system setting").click_secondary(); h.run_steps(4); h.get_by_label("Appearance settings…").click(); h.run_steps(4); From 1240c95592948b85a397da5ef7f0add9595ca888 Mon Sep 17 00:00:00 2001 From: Adrien Alberti Date: Thu, 22 Oct 2026 17:43:59 +0200 Subject: [PATCH 08/59] M12: keep the 1:N field in step with the scale fields; review fixes - ratio_text is derived from the scale fields (one function, sync_ratio_text): on page seeding and after a hand edit of drawing points, real distance, unit or page unit it shows the matching 1:N, or is emptied when the fields are not a plain m/mm drawing scale. The "Common scales" highlight follows it. - format_ratio no longer rounds (1:1.234, 1:0.001), so the field shows exactly the ratio that applies. - The smallest accepted ratio is 1:0.001, keeping the real distance within the minimum of its field; set_ratio refuses out-of-range ratios too. - "Scale", "Common scales", "Enter a scale such as 1:50." and a hint that applying a scale switches units to m / mm are in every i18n catalog, with a completeness test. Co-Authored-By: Claude --- crates/ui-egui/src/i18n/ar.tsv | 4 ++ crates/ui-egui/src/i18n/bg.tsv | 4 ++ crates/ui-egui/src/i18n/ca.tsv | 4 ++ crates/ui-egui/src/i18n/ckb.tsv | 4 ++ crates/ui-egui/src/i18n/cs.tsv | 4 ++ crates/ui-egui/src/i18n/de.tsv | 4 ++ crates/ui-egui/src/i18n/el.tsv | 4 ++ crates/ui-egui/src/i18n/es.tsv | 4 ++ crates/ui-egui/src/i18n/fi.tsv | 4 ++ crates/ui-egui/src/i18n/fr.tsv | 4 ++ crates/ui-egui/src/i18n/hi.tsv | 4 ++ crates/ui-egui/src/i18n/hu.tsv | 4 ++ crates/ui-egui/src/i18n/id.tsv | 4 ++ crates/ui-egui/src/i18n/it.tsv | 4 ++ crates/ui-egui/src/i18n/ja.tsv | 4 ++ crates/ui-egui/src/i18n/kk.tsv | 4 ++ crates/ui-egui/src/i18n/ko.tsv | 4 ++ crates/ui-egui/src/i18n/lo.tsv | 4 ++ crates/ui-egui/src/i18n/lt.tsv | 4 ++ crates/ui-egui/src/i18n/mod.rs | 12 ++++ crates/ui-egui/src/i18n/nb.tsv | 4 ++ crates/ui-egui/src/i18n/pl.tsv | 4 ++ crates/ui-egui/src/i18n/pt-br.tsv | 4 ++ crates/ui-egui/src/i18n/ro.tsv | 4 ++ crates/ui-egui/src/i18n/ru.tsv | 4 ++ crates/ui-egui/src/i18n/sl.tsv | 4 ++ crates/ui-egui/src/i18n/sq.tsv | 4 ++ crates/ui-egui/src/i18n/te.tsv | 4 ++ crates/ui-egui/src/i18n/th.tsv | 4 ++ crates/ui-egui/src/i18n/tr.tsv | 4 ++ crates/ui-egui/src/i18n/uk.tsv | 4 ++ crates/ui-egui/src/i18n/vi.tsv | 4 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 4 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 4 ++ crates/ui-egui/src/measure_ui.rs | 92 ++++++++++++++++++++++++++--- 35 files changed, 229 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 765bbba55..00e0aeed3 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2040,6 +2040,10 @@ Perimeter المحيط Area المساحة Drawing scale مقياس الرسم + Scale المقياس + Common scales مقاييس شائعة + Enter a scale such as 1:50. أدخل مقياسًا مثل 1:50. + Applying a scale switches units to m / mm. يؤدي تطبيق المقياس إلى تحويل الوحدات إلى م / مم. Snapping الانجذاب Export measurements تصدير القياسات Open a PDF to measure it. افتح ملف PDF لقياسه. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9bb377a93..00f89fa74 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1902,6 +1902,10 @@ Perimeter Периметър Area Площ Drawing scale Мащаб на чертежа + Scale Мащаб + Common scales Често използвани мащаби + Enter a scale such as 1:50. Въведете мащаб, например 1:50. + Applying a scale switches units to m / mm. Прилагането на мащаб превключва единиците на m / mm. Snapping Прилепване Export measurements Експортирай измерванията Open a PDF to measure it. Отворете PDF файл, за да го измервате. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9cad6ad5e..7e5f89039 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2000,6 +2000,10 @@ action wizard Start Inicia Perimeter Perímetre Area Àrea Drawing scale Escala del dibuix + Scale Escala + Common scales Escales habituals + Enter a scale such as 1:50. Introduïu una escala com 1:50. + Applying a scale switches units to m / mm. En aplicar una escala, les unitats canvien a m / mm. Snapping Ajust Export measurements Exporta les mesures Open a PDF to measure it. Obre un PDF per mesurar-lo. diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 804e7a97a..e4a7362c1 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2003,6 +2003,10 @@ Perimeter چێوە Area ڕووبەر Drawing scale پێوەری وێنەکێشان + Scale پێوەر + Common scales پێوەرە باوەکان + Enter a scale such as 1:50. پێوەرێک بنووسە وەک 1:50. + Applying a scale switches units to m / mm. جێبەجێکردنی پێوەر یەکەکان دەگۆڕێت بۆ م / ملم. Snapping لکاندن Export measurements هەناردەکردنی پێوانەکان Open a PDF to measure it. PDFێک بکەرەوە بۆ پێوانەکردنی. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4e736bbf4..7da850f2b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2087,6 +2087,10 @@ key Space Mezerník Perimeter Obvod Area Plocha Drawing scale Měřítko výkresu + Scale Měřítko + Common scales Běžná měřítka + Enter a scale such as 1:50. Zadejte měřítko, například 1:50. + Applying a scale switches units to m / mm. Použití měřítka přepne jednotky na m / mm. Snapping Přichytávání Export measurements Exportovat měření Open a PDF to measure it. Otevřete PDF, které chcete měřit. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 76366d452..1bde60ae1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1976,6 +1976,10 @@ Perimeter Umfang Area Fläche Drawing scale Zeichnungsmaßstab + Scale Maßstab + Common scales Gängige Maßstäbe + Enter a scale such as 1:50. Geben Sie einen Maßstab wie 1:50 ein. + Applying a scale switches units to m / mm. Beim Anwenden eines Maßstabs werden die Einheiten auf m / mm umgestellt. Snapping Einrasten Export measurements Messungen exportieren Open a PDF to measure it. Öffne ein PDF, um es zu messen. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 18464664a..7d6922552 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1417,6 +1417,10 @@ Perimeter Περίμετρος Area Εμβαδόν Drawing scale Κλίμακα σχεδίου + Scale Κλίμακα + Common scales Συνηθισμένες κλίμακες + Enter a scale such as 1:50. Εισαγάγετε μια κλίμακα, όπως 1:50. + Applying a scale switches units to m / mm. Η εφαρμογή κλίμακας αλλάζει τις μονάδες σε m / mm. Snapping Προσκόλληση Export measurements Εξαγωγή μετρήσεων Open a PDF to measure it. Ανοίξτε ένα PDF για να πραγματοποιήσετε μετρήσεις. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index db00c69dd..2dfc38749 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2056,6 +2056,10 @@ action wizard Start Iniciar Perimeter Perímetro Area Área Drawing scale Escala del dibujo + Scale Escala + Common scales Escalas habituales + Enter a scale such as 1:50. Introduzca una escala como 1:50. + Applying a scale switches units to m / mm. Al aplicar una escala, las unidades cambian a m / mm. Snapping Ajuste Export measurements Exportar mediciones Open a PDF to measure it. Abre un PDF para medirlo. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ddc29982..81b796471 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1978,6 +1978,10 @@ Perimeter Ympärysmitta Area Pinta-ala Drawing scale Piirustuksen mittakaava + Scale Mittakaava + Common scales Yleiset mittakaavat + Enter a scale such as 1:50. Anna mittakaava, esimerkiksi 1:50. + Applying a scale switches units to m / mm. Mittakaavan käyttö vaihtaa yksiköiksi m / mm. Snapping Tartunta Export measurements Vie mittaukset Open a PDF to measure it. Avaa PDF mitataksesi sitä. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ec6e59e56..490837d56 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1971,6 +1971,10 @@ Perimeter Périmètre Area Aire Drawing scale Échelle du dessin + Scale Échelle + Common scales Échelles courantes + Enter a scale such as 1:50. Saisissez une échelle comme 1:50. + Applying a scale switches units to m / mm. Appliquer une échelle passe les unités en m / mm. Snapping Accrochage Export measurements Exporter les mesures Open a PDF to measure it. Ouvrez un PDF pour le mesurer. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7be5ec688..bcdcac9a3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1961,6 +1961,10 @@ Perimeter परिमाप Area क्षेत्रफल Drawing scale रेखाचित्र पैमाना + Scale पैमाना + Common scales सामान्य पैमाने + Enter a scale such as 1:50. 1:50 जैसा पैमाना दर्ज करें। + Applying a scale switches units to m / mm. पैमाना लागू करने पर इकाइयाँ m / mm में बदल जाती हैं। Snapping स्नैपिंग Export measurements मापन निर्यात करें Open a PDF to measure it. मापने के लिए एक पीडीएफ़ खोलें। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f1723629c..ba244a898 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1961,6 +1961,10 @@ Perimeter Kerület Area Terület Drawing scale Rajz méretaránya + Scale Méretarány + Common scales Gyakori méretarányok + Enter a scale such as 1:50. Adjon meg egy méretarányt, például 1:50. + Applying a scale switches units to m / mm. A méretarány alkalmazása m / mm mértékegységre vált. Snapping Illesztés Export measurements Mérések exportálása Open a PDF to measure it. Nyiss meg egy PDF-et a méréshez. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3585b344e..58a461382 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1390,6 +1390,10 @@ Draw freehand Gambar bebas Drawing Gambar Drawing scale Skala gambar + Scale Skala + Common scales Skala umum + Enter a scale such as 1:50. Masukkan skala seperti 1:50. + Applying a scale switches units to m / mm. Menerapkan skala mengubah satuan menjadi m / mm. Drop-down list Daftar drop-down Duplicate pages Duplikat halaman Edit a PDF Edit PDF diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0dc6bb087..49f622af5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2047,6 +2047,10 @@ Perimeter Perimetro Area Area Drawing scale Scala del disegno + Scale Scala + Common scales Scale comuni + Enter a scale such as 1:50. Inserisci una scala come 1:50. + Applying a scale switches units to m / mm. Applicando una scala le unità passano a m / mm. Snapping Aggancio Export measurements Esporta misure Open a PDF to measure it. Apri un PDF per misurarlo. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 963e8d364..73639521f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2054,6 +2054,10 @@ Perimeter 周長 Area 面積 Drawing scale 図面の縮尺 + Scale 縮尺 + Common scales よく使う縮尺 + Enter a scale such as 1:50. 1:50 のように縮尺を入力してください。 + Applying a scale switches units to m / mm. 縮尺を適用すると、単位が m / mm に切り替わります。 Snapping スナップ Export measurements 測定結果を書き出す Open a PDF to measure it. 測定するPDFを開いてください。 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 311626f34..907877336 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -1962,6 +1962,10 @@ Perimeter Периметр Area Аудан Drawing scale Сызба масштабы + Scale Масштаб + Common scales Жиі қолданылатын масштабтар + Enter a scale such as 1:50. 1:50 сияқты масштабты енгізіңіз. + Applying a scale switches units to m / mm. Масштабты қолдану өлшем бірліктерін m / mm етіп ауыстырады. Snapping Түйістіру Export measurements Өлшемдерді экспорттау Open a PDF to measure it. Өлшеу үшін PDF құжатын ашыңыз. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 48ef31e19..bfda232cc 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1961,6 +1961,10 @@ Perimeter 둘레 Area 면적 Drawing scale 도면 축척 + Scale 축척 + Common scales 자주 쓰는 축척 + Enter a scale such as 1:50. 1:50과 같은 축척을 입력하세요. + Applying a scale switches units to m / mm. 축척을 적용하면 단위가 m / mm로 바뀝니다. Snapping 스냅 Export measurements 측정값 내보내기 Open a PDF to measure it. 측정할 PDF를 여세요. diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index a8a9f6f64..4aa8e4a6d 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2014,6 +2014,10 @@ Perimeter ລວງຮອບ Area ເນື້ອທີ່ Drawing scale ມາດຕາສ່ວນຮູບແຕ້ມ + Scale ມາດຕາສ່ວນ + Common scales ມາດຕາສ່ວນທີ່ໃຊ້ທົ່ວໄປ + Enter a scale such as 1:50. ປ້ອນມາດຕາສ່ວນ ເຊັ່ນ 1:50. + Applying a scale switches units to m / mm. ການນຳໃຊ້ມາດຕາສ່ວນຈະປ່ຽນຫົວໜ່ວຍເປັນ m / mm. Snapping ການດູດຈັບ Export measurements ສົ່ງອອກການວັດແທກ Open a PDF to measure it. ເປີດ PDF ເພື່ອວັດແທກ. diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 0e542211f..2b9d886ec 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -1449,6 +1449,10 @@ Draw freehand Brėžti laisva ranka Drawing Brėžinys Drawing scale Brėžinio mastelis + Scale Mastelis + Common scales Dažni masteliai + Enter a scale such as 1:50. Įveskite mastelį, pavyzdžiui, 1:50. + Applying a scale switches units to m / mm. Pritaikius mastelį, vienetai pakeičiami į m / mm. Drop-down list Išskleidžiamasis sąrašas Duplicate pages Dubliuoti puslapius Edit a PDF Redaguoti PDF diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index dff8efc3d..76d530dc1 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -1102,6 +1102,18 @@ mod tests { } } + /// The drawing-scale labels of the Measure panel are translated in every catalog. + #[test] + fn drawing_scale_labels_are_translated_in_every_catalog() { + for language in LANGUAGES.iter().filter(|language| !language.source.is_empty()) { + let lang = Lang::from_code(language.code).expect("registered language"); + for label in ["Scale", "Common scales", "Enter a scale such as 1:50.", "Applying a scale switches units to m / mm."] { + assert!(has(lang, label), "{} lacks {label:?}", language.code); + assert_ne!(tr(lang, label), label, "{} falls back to English for {label:?}", language.code); + } + } + } + /// Every catalog says the labels of a visible signature, which are looked up by the sign /// crate's own English words (so the `tl!` scan can't see them), and the note shown when they /// fall back to English. Each label that runs into its value keeps a separator at its end. diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 27c1383ec..62a2a9a49 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -1401,6 +1401,10 @@ Draw freehand Tegn på frihånd Drawing Tegning Drawing scale Tegneskala + Scale Målestokk + Common scales Vanlige målestokker + Enter a scale such as 1:50. Skriv inn en målestokk, for eksempel 1:50. + Applying a scale switches units to m / mm. Når du bruker en målestokk, byttes enhetene til m / mm. Drop-down list Nedtrekksliste Duplicate pages Dupliser sider Edit a PDF Rediger en PDF diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cd381b539..c0e0d03eb 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2034,6 +2034,10 @@ Perimeter Obwód Area Pole powierzchni Drawing scale Skala rysunku + Scale Skala + Common scales Popularne skale + Enter a scale such as 1:50. Wpisz skalę, na przykład 1:50. + Applying a scale switches units to m / mm. Zastosowanie skali zmienia jednostki na m / mm. Snapping Przyciąganie Export measurements Eksportuj pomiary Open a PDF to measure it. Otwórz PDF, aby wykonać pomiar. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8fe72f99b..3e136ff00 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1774,6 +1774,10 @@ Perimeter Perímetro Area Área Drawing scale Escala de desenho + Scale Escala + Common scales Escalas comuns + Enter a scale such as 1:50. Digite uma escala como 1:50. + Applying a scale switches units to m / mm. Aplicar uma escala muda as unidades para m / mm. Snapping Alinhamento inteligente Export measurements Exportar medições Open a PDF to measure it. Abra um PDF para medi-lo. diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 5a8025dc8..ef0eb30b9 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -1399,6 +1399,10 @@ Draw freehand Desenare liberă Drawing Desen Drawing scale Scara desenului + Scale Scară + Common scales Scări uzuale + Enter a scale such as 1:50. Introduceți o scară, de exemplu 1:50. + Applying a scale switches units to m / mm. Aplicarea unei scări schimbă unitățile în m / mm. Drop-down list Listă derulantă Duplicate pages Duplicare pagini Edit a PDF Editare PDF diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0dc40fdba..681a41a2f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2041,6 +2041,10 @@ Perimeter Периметр Area Площадь Drawing scale Масштаб чертежа + Scale Масштаб + Common scales Распространённые масштабы + Enter a scale such as 1:50. Введите масштаб, например 1:50. + Applying a scale switches units to m / mm. При применении масштаба единицы переключаются на м / мм. Snapping Привязка Export measurements Экспортировать измерения Open a PDF to measure it. Откройте PDF для измерения. diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index f5e374ae7..5fc2c3a62 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -654,6 +654,10 @@ Draw your {what} below. Spodaj narišite: {what}. Drawing Risba Drawing scale Merilo risbe + Scale Merilo + Common scales Pogosta merila + Enter a scale such as 1:50. Vnesite merilo, na primer 1:50. + Applying a scale switches units to m / mm. Z uporabo merila se enote preklopijo na m / mm. Drop PDFs or folders here, or choose them. Spustite datoteke PDF ali mape sem ali jih izberite. Drop-down list Spustni seznam Dropdown Properties Lastnosti spustnega seznama diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index c93efee5a..a89c990b2 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -645,6 +645,10 @@ Draw your {what} below. Vizato {what} më poshtë. Drawing Vizatim Drawing scale Shkalla e vizatimit + Scale Shkalla + Common scales Shkalla të zakonshme + Enter a scale such as 1:50. Shkruani një shkallë si 1:50. + Applying a scale switches units to m / mm. Zbatimi i një shkalle i kalon njësitë në m / mm. Drop PDFs or folders here, or choose them. Hidh PDF ose dosje këtu, ose zgjidh ato. Drop-down list Listë rënëse Dropdown Properties Vetitë e listës rënëse diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index fa3f6cec4..3b7d879d5 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1960,6 +1960,10 @@ action wizard Start ప్రారంభించు Perimeter చుట్టుకొలత Area వైశాల్యం Drawing scale డ్రాయింగ్ స్కేల్ + Scale స్కేల్ + Common scales సాధారణ స్కేల్‌లు + Enter a scale such as 1:50. 1:50 వంటి స్కేల్‌ను నమోదు చేయండి. + Applying a scale switches units to m / mm. స్కేల్‌ను వర్తింపజేస్తే యూనిట్లు m / mm‌కు మారతాయి. Snapping స్నాపింగ్ Export measurements కొలతలను ఎగుమతి చేయి Open a PDF to measure it. కొలవడానికి ఒక PDFను తెరవండి. diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index e56770757..d4c023015 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -1984,6 +1984,10 @@ Perimeter เส้นรอบรูป Area พื้นที่ Drawing scale มาตราส่วนของแบบ + Scale มาตราส่วน + Common scales มาตราส่วนที่ใช้บ่อย + Enter a scale such as 1:50. ป้อนมาตราส่วน เช่น 1:50 + Applying a scale switches units to m / mm. การใช้มาตราส่วนจะเปลี่ยนหน่วยเป็น m / mm Snapping การยึด Export measurements ส่งออกการวัด Open a PDF to measure it. เปิด PDF เพื่อวัดระยะ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c292b8863..64d8aa154 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1934,6 +1934,10 @@ Perimeter Çevre Area Alan Drawing scale Çizim ölçeği + Scale Ölçek + Common scales Yaygın ölçekler + Enter a scale such as 1:50. 1:50 gibi bir ölçek girin. + Applying a scale switches units to m / mm. Ölçek uygulamak birimleri m / mm olarak değiştirir. Snapping Yakalama Export measurements Ölçümleri dışa aktar Open a PDF to measure it. Ölçmek için bir PDF açın. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cc8f45765..96136a425 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2038,6 +2038,10 @@ Perimeter Периметр Area Площа Drawing scale Масштаб креслення + Scale Масштаб + Common scales Поширені масштаби + Enter a scale such as 1:50. Введіть масштаб, наприклад 1:50. + Applying a scale switches units to m / mm. Застосування масштабу перемикає одиниці на м / мм. Snapping Прив’язування Export measurements Експортувати вимірювання Open a PDF to measure it. Відкрийте PDF для вимірювання. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bb20dcc5b..d60a8de88 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1439,6 +1439,10 @@ theme Appearance Giao diện Draw freehand Vẽ tự do Drawing Bản vẽ Drawing scale Tỷ lệ bản vẽ + Scale Tỷ lệ + Common scales Tỷ lệ thông dụng + Enter a scale such as 1:50. Nhập một tỷ lệ như 1:50. + Applying a scale switches units to m / mm. Áp dụng tỷ lệ sẽ chuyển đơn vị sang m / mm. Drop-down list Danh sách thả xuống Duplicate pages Nhân bản trang Edit a PDF Chỉnh sửa một PDF diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 594f68df8..fc2bb4007 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1910,6 +1910,10 @@ Perimeter 周长 Area 面积 Drawing scale 图面比例 + Scale 比例 + Common scales 常用比例 + Enter a scale such as 1:50. 请输入比例,例如 1:50。 + Applying a scale switches units to m / mm. 应用比例会将单位切换为 m / mm。 Snapping 吸附 Export measurements 导出测量结果 Open a PDF to measure it. 打开 PDF 以进行测量。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb578ba26..f0ac381b4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2041,6 +2041,10 @@ Perimeter 周長 Area 面積 Drawing scale 圖面比例 + Scale 比例 + Common scales 常用比例 + Enter a scale such as 1:50. 請輸入比例,例如 1:50。 + Applying a scale switches units to m / mm. 套用比例會將單位切換為 m / mm。 Snapping 吸附 Export measurements 匯出測量結果 Open a PDF to measure it. 開啟 PDF 以進行測量。 diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index d80ecca3e..658457499 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -115,16 +115,40 @@ pub(crate) fn parse_ratio(text: &str) -> Option { Some((one, n)) => n.trim().parse::().ok()? / one.trim().parse::().ok()?, None => text.parse::().ok()?, }; - (n.is_finite() && (1e-6..=1e9).contains(&n)).then_some(n) + RATIO_RANGE.contains(&n).then_some(n) } -/// "1:200", or "1:2.5" for a ratio that isn't a whole number. +/// The N of 1:N this panel accepts. The lower bound keeps the real distance (N / 1000 m) within the +/// minimum of its field. +const RATIO_RANGE: std::ops::RangeInclusive = 1e-3..=1e9; +/// "1:200", or "1:2.5" for a ratio that isn't a whole number: the number as it is, so the field shows exactly what applies. pub(crate) fn format_ratio(n: f64) -> String { - let rounded = (n * 100.0).round() / 100.0; - if (rounded - rounded.round()).abs() < 1e-9 { format!("1:{}", rounded.round()) } else { format!("1:{rounded}") } + format!("1:{n}") +} +/// The N of 1:N the scale fields stand for, when they are a plain drawing scale (real distance in m or mm). +fn ratio_of_fields(state: &MeasureView, display: crate::units::Unit) -> Option { + let real_mm = match state.unit.trim() { + "m" => state.real_distance * 1000.0, + "mm" => state.real_distance, + _ => return None, + }; + let paper_mm = state.drawing_points * page_unit_of(state.page_unit, &state.unit, display).points() * 25.4 / 72.0; + let n = real_mm / paper_mm; + // Float noise from the unit conversions is rounded away; anything that isn't a clean ratio isn't one. + let clean = (n * 1e6).round() / 1e6; + (RATIO_RANGE.contains(&clean) && (clean - n).abs() <= 1e-9 * n).then_some(clean) +} +/// Make the "1:N" field and the common scale picked in its list say what the scale fields now hold: the ratio, or nothing. +fn sync_ratio_text(state: &mut MeasureView, display: crate::units::Unit) { + state.ratio_text = ratio_of_fields(state, display).map(format_ratio).unwrap_or_default(); + state.ratio_dirty = false; +} +/// What the scale fields hold, to notice a hand edit. +fn scale_fields(state: &MeasureView) -> (f64, f64, String, Option) { + (state.drawing_points, state.real_distance, state.unit.clone(), state.page_unit) } /// Fill the scale fields for 1:N: 1 mm on the page is N mm, shown in metres. Returns `false`, changing nothing, when `n` is not a usable ratio. pub(crate) fn set_ratio(state: &mut MeasureView, n: f64) -> bool { - if !n.is_finite() || n <= 0.0 { + if !RATIO_RANGE.contains(&n) { return false; } state.unit = "m".into(); @@ -365,6 +389,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { state.page_unit = None; state.precision = scale.precision; } + sync_ratio_text(state, units); } ui.label(tl!("Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area.")); ui.label(tl!("Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels.")); @@ -400,8 +425,9 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.separator(); // "1:N" fills the fields below and applies at once, as does picking a common scale. let mut chosen = None; + let fields = scale_fields(state); ui.horizontal(|ui| { - ui.label(tl!("Drawing scale")); + ui.label(tl!("Drawing scale")).on_hover_text(tl!("Applying a scale switches units to m / mm.")); let typed = ui.add(crate::widgets::line(&mut state.ratio_text).hint_text("1:100").desired_width(56.0).char_limit(16)); typed.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tl!("Scale"))); state.ratio_dirty |= typed.changed(); @@ -437,6 +463,10 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.add(crate::widgets::line(&mut state.unit).desired_width(64.0).char_limit(12)); ui.add(egui::DragValue::new(&mut state.precision).range(0..=6).prefix(format!("{} ", tl!("Decimals")))); }); + // A hand edit of the fields leaves the 1:N field showing the old ratio: say the new one. + if scale_fields(state) != fields { + sync_ratio_text(state, units); + } if ui.button(tl!("Calibrate from two points")).clicked() { tool = Some(Tool::Calibrate); } @@ -597,11 +627,15 @@ mod tests { assert_eq!(parse_ratio("250"), Some(250.0)); assert_eq!(parse_ratio("1:2,5"), Some(2.5)); assert_eq!(parse_ratio("2:1"), Some(0.5)); + assert_eq!(parse_ratio("1:0.001"), Some(1e-3)); } #[test] fn invalid_ratios_are_refused() { - for bad in ["", " ", ":", "1:", ":50", "1:0", "0", "-5", "1:-5", "0:5", "abc", "1:abc", "1:50:2", "1:inf", "NaN", "1e12", "1:1e12"] { + for bad in [ + "", " ", ":", "1:", ":50", "1:0", "0", "-5", "1:-5", "0:5", "abc", "1:abc", "1:50:2", "1:inf", "NaN", "1e12", "1:1e12", "1:0.0001", + "1:1e-6", + ] { assert_eq!(parse_ratio(bad), None, "{bad:?}"); } } @@ -611,6 +645,10 @@ mod tests { assert_eq!(format_ratio(50.0), "1:50"); assert_eq!(format_ratio(2.5), "1:2.5"); assert_eq!(format_ratio(0.5), "1:0.5"); + // Nothing is rounded away: the field reads the ratio that applies. + assert_eq!(format_ratio(1.234), "1:1.234"); + assert_eq!(format_ratio(0.001), "1:0.001"); + assert_eq!(parse_ratio(&format_ratio(333.333333)), Some(333.333333)); for n in RATIOS { assert_eq!(parse_ratio(&format_ratio(n)), Some(n)); } @@ -641,6 +679,46 @@ mod tests { assert!((v - 0.05).abs() < 1e-9, "{v}"); } + #[test] + fn the_smallest_ratio_keeps_the_real_distance_within_its_field() { + let mut state = MeasureView::default(); + assert!(set_ratio(&mut state, parse_ratio("1:0.001").expect("ratio"))); + // The real-distance field accepts 0.000001 and no less. + assert!(state.real_distance >= 0.000001, "{}", state.real_distance); + assert!(!set_ratio(&mut state, 1e-4)); + } + + #[test] + fn the_ratio_field_follows_the_scale_fields() { + let display = crate::units::Unit::Points; + let mut state = MeasureView::default(); + for n in [50.0, 2.5, 1.234, 0.001, 1e9] { + assert!(set_ratio(&mut state, n)); + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, format_ratio(n), "1:{n}"); + } + // A hand edit that is still a drawing scale reads as its ratio, in another page unit too. + assert!(set_ratio(&mut state, 50.0)); + state.page_unit = Some(PageUnit::Cm); + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, "1:5"); + state.page_unit = Some(PageUnit::Mm); + state.real_distance = 0.1; + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, "1:100"); + // One that isn't (another unit, or no ratio at all) empties it. + state.unit = "ft".into(); + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, ""); + state.unit = "m".into(); + state.real_distance = 0.1 / 3.0; + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, ""); + state.drawing_points = 0.0; + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, ""); + } + #[test] fn an_unusable_ratio_changes_nothing() { let mut state = MeasureView::default(); From 01744fd7eb1621188de5cce285135c9d287b421c Mon Sep 17 00:00:00 2001 From: Lombardi2003 Date: Tue, 20 Oct 2026 23:41:17 +0200 Subject: [PATCH 09/59] feat(organize): Extract pages takes typed page ranges (#3593) Extract pages only took the selected pages. Its dialog now has a Pages field for ranges such as "1-5, 8, 11-13", read like Combine files' and Print's ranges, in the order typed and each page once. Left empty (as the dialog opens), it takes the selection as before, shown as the hint. A range it can't use says why and turns Extract off; Extract and delete removes the typed pages. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 30 ++++++++++++---- crates/ui-egui/src/files.rs | 46 ++++++++++++++++++++---- crates/ui-egui/tests/it/pickers.rs | 56 ++++++++++++++++++++++++++++-- parity/acrobat-features.toml | 4 +-- 4 files changed, 119 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0337a8cbf..f4b2aca82 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -594,15 +594,31 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { return; } Dialog::Extract => { - let count = app.active_ids().map_or(0, |(i, _)| app.views[i].target_pages().len()); + let (selected, total) = app.active_ids().map_or((Vec::new(), 0), |(i, _)| (app.views[i].target_pages(), app.views[i].page_count())); ui.label(egui::RichText::new(tl!("Extract pages")).font(theme::semibold(18.0))); ui.add_space(8.0); - let text = if count == 1 { - tl!("1 page selected.").to_string() - } else { - crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &count.to_string())]) + // Page ranges to take instead of the selection (#3593); the hint is the selection. + let hint = match crate::combine_ui::range_of(&selected, total) { + all if all.is_empty() && total > 1 => format!("1-{total}"), + all if all.is_empty() => "1".to_string(), + some => some, }; - ui.label(text); + ui.horizontal(|ui| { + let l = ui.label(tl!("Pages")); + ui.add(egui::TextEdit::singleline(&mut app.extract_draft.pages).hint_text(hint).desired_width(240.0)).labelled_by(l.id); + }); + let chosen = app.extract_draft.pages_to_extract(selected, total); + match &chosen { + Ok(pages) if pages.len() == 1 => { + ui.label(tl!("1 page selected.")); + } + Ok(pages) => { + ui.label(crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &pages.len().to_string())])); + } + Err(e) => { + ui.colored_label(ui.visuals().error_fg_color, e); + } + } ui.add_space(4.0); // The name the pages are saved under (#737); separate files add " (page N)". ui.horizontal(|ui| { @@ -613,7 +629,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add(widgets::checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files"))); ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Extract"), true).clicked() { + if ui.add_enabled_ui(chosen.is_ok(), |ui| widgets::pill_button(ui, tl!("Extract"), true)).inner.clicked() { extract_now = true; close = true; } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 1ec9d383f..299578649 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -166,6 +166,28 @@ pub struct ExtractDraft { /// the document's name when it opens; empty means that name too. As separate files each /// page is ` (page N).pdf`, and a single page the user renamed is `.pdf`. pub name: String, + /// The pages to extract as typed ranges, 1-based ("1-5, 8, 11-13") (#3593). Empty means the + /// selected pages (or the current one); the dialog opens with it empty. + pub pages: String, +} + +impl ExtractDraft { + /// The pages to extract (0-based, in the order typed, each once) from a document of `count` + /// pages: the typed [`Self::pages`] ranges, or `selected` when none are typed. Says what is + /// wrong with a range that can't be read or takes no page. + pub fn pages_to_extract(&self, selected: Vec, count: usize) -> Result, String> { + if self.pages.trim().is_empty() { + return Ok(selected); + } + match pdfcraft_engine::print::select_pages(count, Some(&self.pages), &[], pdfcraft_engine::print::Subset::All, false) { + Ok(p) if p.is_empty() => Err(tl!("No pages selected").to_string()), + Ok(p) => { + let mut seen = std::collections::HashSet::new(); + Ok(p.into_iter().filter(|page| seen.insert(*page)).collect()) + } + Err(e) => Err(e.to_string()), + } + } } /// `name` made safe as a file name on every platform: no path separators, reserved or control @@ -604,23 +626,35 @@ impl PdfCraftApp { } } - /// Show Extract pages, its file name filled in with the active document's (#737). + /// Show Extract pages, its file name filled in with the active document's (#737) and no + /// page ranges typed, so it takes the selected pages until some are (#3593). pub fn open_extract_dialog(&mut self) { self.extract_draft.name = self.active_ids().and_then(|(_, id)| self.session.get(id)).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_default(); + self.extract_draft.pages.clear(); self.dialog = Some(crate::Dialog::Extract); } - /// Copy the selected pages (or the current page) into a new unsaved document tab. + /// Copy the pages typed in Extract pages, else the selected pages (or the current page), into + /// a new unsaved document tab. pub fn extract_selection(&mut self) { // What's typed in a form field is part of the document (#166). if !self.commit_form_typing() { return; } let Some((i, id)) = self.active_ids() else { return }; - let pages = self.views[i].target_pages(); - let stem = self.session.get(id).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_default(); let opts = self.extract_draft.clone(); + let pages = match opts.pages_to_extract(self.views[i].target_pages(), self.views[i].page_count()) { + Ok(pages) => pages, + Err(e) => { + self.notify_fmt("Couldn't extract pages: {e}", &[("e", &e)]); + return; + } + }; + // Deleting takes the pages in document order (typed ranges may run backwards). + let mut doomed = pages.clone(); + doomed.sort_unstable(); + let stem = self.session.get(id).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_default(); // The name typed in the dialog (#737), else the document's. let chosen = file_stem_from_user(&opts.name); let renamed = !chosen.is_empty() && chosen != file_stem_from_user(&stem); @@ -650,7 +684,7 @@ impl PdfCraftApp { } let unchanged = app.session.get(id).map(|d| d.edit_generation()) == generation; if unchanged && app.active_ids().map(|(_, active)| active) == Some(id) { - app.apply_edit(pdfcraft_engine::Edit::DeletePages { pages }); + app.apply_edit(pdfcraft_engine::Edit::DeletePages { pages: doomed }); } else { app.notify_tr("The pages were extracted but not deleted, because the document changed while you were choosing a folder."); } @@ -677,7 +711,7 @@ impl PdfCraftApp { if opts.delete { // Back on the original document. self.active = Some(i); - self.apply_edit(pdfcraft_engine::Edit::DeletePages { pages }); + self.apply_edit(pdfcraft_engine::Edit::DeletePages { pages: doomed }); } } diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index e4ce56d7c..ade218aaa 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -7,8 +7,8 @@ use crate::support; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; -use pdfcraft_render::PageRenderer; +use egui_kittest::kittest::{NodeT, Queryable}; +use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use pdfcraft_ui_egui::PdfCraftApp; /// An `n`-page document with a proper xref table. @@ -255,6 +255,58 @@ fn extract_pages_saves_under_the_file_name_typed_in_the_dialog() { let _ = std::fs::remove_dir_all(&dir); } +/// #3593: Extract pages takes page ranges typed in its Pages field, in the order typed, each page +/// once; left empty, it takes the selected pages as before. A range it can't use is refused, with +/// the reason in the dialog and the Extract button off. +#[test] +fn extract_pages_takes_the_page_ranges_typed_in_the_dialog() { + let texts = |app: &PdfCraftApp, view: usize| { + let doc = app.session.get(app.views[view].id).unwrap(); + let mut r = PageRenderer::new(doc.bytes.clone(), Default::default()); + (0..r.page_count()) + .map(|p| { + let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, scale: 1.0, ..Default::default() }); + out.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default() + }) + .collect::>() + }; + let mut h = harness(&[6]); + h.state_mut().views[0].select_pages(&[1]); + assert!(h.state_mut().execute("page.extract")); + h.run_steps(2); + assert_eq!(h.state().extract_draft.pages, "", "opens on the selection"); + h.get_by_label("Pages"); + h.get_by_label_contains("1 page selected."); + // Ranges, a backward one and a repeat: in that order, each page once. + h.state_mut().extract_draft.pages = "5-6, 3-2, 6".into(); + h.run_steps(2); + h.get_by_label_contains("4 pages selected."); + h.state_mut().extract_selection(); + h.run_steps(2); + let s = h.state(); + assert_eq!(s.views.len(), 2, "the pages open in a new tab"); + assert_eq!(texts(s, 1), ["Page 5", "Page 6", "Page 3", "Page 2"]); + // A page that isn't there: the dialog says so, Extract is off and extracts nothing. + h.state_mut().active = Some(0); + h.state_mut().open_extract_dialog(); + h.state_mut().extract_draft.pages = "2, 9".into(); + h.run_steps(2); + assert!(h.get_by_label("Extract").accesskit_node().is_disabled(), "Extract is off"); + h.state_mut().extract_selection(); + h.run_steps(2); + assert_eq!(h.state().views.len(), 2, "nothing extracted"); + // Extract and delete takes the typed pages out of the document. + h.state_mut().active = Some(0); + h.state_mut().open_extract_dialog(); + h.state_mut().extract_draft.pages = "6, 1-2".into(); + h.state_mut().extract_draft.delete = true; + h.state_mut().extract_selection(); + h.run_steps(2); + let s = h.state(); + assert_eq!(texts(s, 2), ["Page 6", "Page 1", "Page 2"]); + assert_eq!(texts(s, 0), ["Page 3", "Page 4", "Page 5"]); +} + /// Left as the document's name (or emptied), the names are the ones Extract always used. #[test] fn extract_pages_keeps_the_usual_names_when_the_file_name_is_unchanged() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa77..f0ea5933f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2788,8 +2788,8 @@ milestone = "M4" status = "shipped" commands = ["page.extract"] tools = ["page_extract"] -evidence = ["crates/ui-egui/tests/it/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"] -notes = "Carries the PDF/X output intent and identification (#263) in a new XMP packet; the source XMP (dates, other schemas) is not copied." +evidence = ["crates/ui-egui/tests/it/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification", "crates/ui-egui/tests/it/pickers.rs::extract_pages_takes_the_page_ranges_typed_in_the_dialog"] +notes = "Carries the PDF/X output intent and identification (#263) in a new XMP packet; the source XMP (dates, other schemas) is not copied. The dialog's Pages field takes page ranges (\"1-5, 8, 11-13\", in the order typed, each page once) instead of the selection (#3593); page labels are not accepted there yet." [[feature]] id = "organize.extract-separate-files" From 19f40e6554415bd897c5315e4863762625d3a557 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 21:21:00 +0500 Subject: [PATCH 10/59] The two file tests ask which file, not how the OS says it is missing Review of #3849: both tests asserted `stderr.contains("No such file")`, which is the Unix wording. On Windows the same error reads "The system cannot find the file specified. (os error 2)", so `test (windows-latest)` failed on them. Neither test is about the wording. What they are about: - `a_flag_that_takes_no_value_does_not_swallow_the_file`: the error is about `missing.pdf`, and is not "check-one: missing file" -- the file reached the command instead of being eaten as `--edit`'s operand. - `a_file_whose_name_starts_with_dashes_is_still_reachable`: the error names the file the command could not open (`missing.pdf`, or `./--dash.pdf` for the `info` case), and is not a usage error about a value or an unknown option. Both now assert exactly that, plus the absence of our own usage phrases. Every string they match is one this CLI prints, so the OS has no say in it. --- apps/pdfcraft-cli/tests/options.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/pdfcraft-cli/tests/options.rs b/apps/pdfcraft-cli/tests/options.rs index 14b3e617c..7955157e2 100644 --- a/apps/pdfcraft-cli/tests/options.rs +++ b/apps/pdfcraft-cli/tests/options.rs @@ -65,16 +65,19 @@ fn a_file_option_refuses_a_value_that_is_another_option() { /// is as far as these lines get here. #[test] fn a_file_whose_name_starts_with_dashes_is_still_reachable() { - for args in [ - vec!["extract", "missing.pdf", "--pages", "1", "--out", "./--dash.pdf"], - vec!["edit", "missing.pdf", "--out", "./--dash.pdf"], - vec!["info", "./--dash.pdf"], + // The error is about the file the command could not open, so the test asks which file it + // names -- not how the OS words "not found", which differs on Windows (#3849 review). + for (names, args) in [ + ("missing.pdf", vec!["extract", "missing.pdf", "--pages", "1", "--out", "./--dash.pdf"]), + ("missing.pdf", vec!["edit", "missing.pdf", "--out", "./--dash.pdf"]), + ("./--dash.pdf", vec!["info", "./--dash.pdf"]), ] { let out = Command::new(BIN).args(&args).output().expect("run cli"); let stderr = String::from_utf8_lossy(&out.stderr); assert!(!out.status.success(), "{args:?}: no such file either way"); - assert!(stderr.contains("No such file"), "{args:?}: {stderr}"); - assert!(!stderr.contains("needs a value"), "{args:?}: {stderr}"); + assert!(stderr.contains(names), "{args:?}: the error is about {names}: {stderr}"); + // Not a usage error: the `./--dash.pdf` reached the command as a file name. + assert!(!stderr.contains("needs a value") && !stderr.contains("unknown option"), "{args:?}: {stderr}"); } } @@ -86,8 +89,10 @@ fn a_flag_that_takes_no_value_does_not_swallow_the_file() { let out = Command::new(BIN).args(&args).output().expect("run cli"); let stderr = String::from_utf8_lossy(&out.stderr); assert!(!out.status.success(), "{args:?}"); - assert!(stderr.contains("missing.pdf") && stderr.contains("No such file"), "{args:?}: {stderr}"); + // Which file the error is about, not how the OS says it is missing (#3849 review). + assert!(stderr.contains("missing.pdf"), "{args:?}: the error is about the file: {stderr}"); assert!(!stderr.contains("missing file"), "the file was found, and then not found: {stderr}"); + assert!(!stderr.contains("needs a value"), "{args:?}: {stderr}"); } } From 9a76f90f070e0b3d11f61bf42e4e30a7da26b43f Mon Sep 17 00:00:00 2001 From: Oliver Hagmann <268387159+ohagmann@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:38:28 +0200 Subject: [PATCH 11/59] M5: the arrow keys move the selected comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a comment selected, the arrow keys scrolled the view (↑ ↓) or turned the page (← →), so a text box, rectangle or line could only be placed with the mouse. Now an arrow key moves the selected comment a screen pixel, and ⌘/Ctrl or Shift and an arrow ten, as Edit text nudges objects; up is up on screen whatever the zoom, the page's rotation and the view's. Each press is one undo step (Edit::MoveAnnotation). The keys are taken before the view's navigation, which has them back once nothing is selected. Text markup stays with its text, and a locked comment where it is. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 ++ crates/ui-egui/src/comments.rs | 49 +++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/tests/it/comments.rs | 44 ++++++++++++++++++++++++++ docs/ui-parity.md | 2 +- parity/acrobat-features.toml | 12 +++++++ 6 files changed, 110 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index bc057209c..9164899c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -225,6 +225,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged. - **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged. +- **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged. + - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. - **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index d80f988b7..541e1c8ad 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1823,6 +1823,55 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool } } +/// The arrow keys move the selected comment a screen pixel; with ⌘/Ctrl or Shift, ten. Up is up +/// on screen whatever the zoom, the page's rotation and the view's; the keys are taken before +/// the view scrolls or turns the page with them. Each press is one undo step. Text markup stays +/// with its text, and a locked comment where it is. +pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) { + if ctx.egui_wants_keyboard_input() || view.pending_edit.is_some() { + return; + } + let cv = &view.comments; + if cv.gesture.is_some() || cv.composer.is_some() { + return; + } + let Some((page, index)) = cv.selected else { return }; + let movable = + info.annotations.iter().any(|a| a.page == page && a.index == index && a.in_reply_to.is_none() && !is_markup(&a.subtype) && !a.locked); + let (Some(xf), Some(p)) = (view.page_xform(page), info.pages.get(page)) else { return }; + if !movable { + return; + } + use egui::{Key, Modifiers}; + let mut step = egui::Vec2::ZERO; + ctx.input_mut(|i| { + for (key, direction) in + [(Key::ArrowLeft, vec2(-1.0, 0.0)), (Key::ArrowRight, vec2(1.0, 0.0)), (Key::ArrowUp, vec2(0.0, -1.0)), (Key::ArrowDown, vec2(0.0, 1.0))] + { + // The modified patterns first: a plain one also matches a Shift press. + if i.consume_key(Modifiers::COMMAND, key) || i.consume_key(Modifiers::SHIFT, key) { + step += direction * 10.0; + } else if i.consume_key(Modifiers::NONE, key) { + step += direction; + } + } + }); + if step == egui::Vec2::ZERO { + return; + } + // The step on screen, in user space: from the page's centre to `step` pixels away. + let user = |s: Pos2| { + let (vx, vy) = xf.screen_to_view(s); + let u = p.view_to_user(vx, vy); + [f64::from(u[0]), f64::from(u[1])] + }; + let (a, b) = (user(xf.rect.center()), user(xf.rect.center() + step)); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + if dx.is_finite() && dy.is_finite() && (dx != 0.0 || dy != 0.0) { + view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy }); + } +} + /// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's /// menus as in the Attachments panel: the comment is where the reader finds its file. Reading /// the file changes nothing, so a read-only document offers them too. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64b22ff18..d618e4ba2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3023,6 +3023,8 @@ impl PdfCraftApp { && let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification()) { object_ui::nudge_keys(ctx, &mut self.views[i], doc); + } else if let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_annotation()) { + comments::nudge_keys(ctx, &mut self.views[i], &doc.info); } // A selected field moves with the arrows too (Prepare a form, #2608), before the view // turns the page with them. diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6194c7516..5e6227843 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -860,6 +860,50 @@ fn line_ends_are_dragged_to_change_length_and_angle() { assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "{p:?}"); } +/// The arrow keys move the selected comment a screen pixel, ⌘/Ctrl or Shift and an arrow ten, +/// instead of scrolling or turning the page; each press is one undo step. Without a selection +/// the arrows scroll and turn pages as before. +#[test] +fn arrow_keys_nudge_the_selected_comment() { + use egui::{Key, Modifiers}; + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + h.state_mut().set_option("quick", "select").unwrap(); + click_pt(&mut h, (90.0, 70.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0))); + // One screen pixel in user space, at this zoom. + let pixel = 300.0 / h.state().views[0].page_screen_rect(0).unwrap().width(); + let before = comments(&h)[0].rect; + h.key_press(Key::ArrowRight); + h.run_steps(3); + let r = comments(&h)[0].rect; + assert!((r[0] - before[0] - pixel).abs() < 0.01 && (r[1] - before[1]).abs() < 0.01, "one pixel right: {r:?} from {before:?}"); + assert_eq!(h.state().views[0].current, 0, "the page didn't turn"); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comment")); + // ⌘/Ctrl and Shift: ten pixels (down on screen is down in user space here). + h.key_press_modifiers(Modifiers::COMMAND, Key::ArrowDown); + h.run_steps(3); + let r2 = comments(&h)[0].rect; + assert!((r[1] - r2[1] - 10.0 * pixel).abs() < 0.01 && (r2[0] - r[0]).abs() < 0.01, "ten pixels down: {r2:?} from {r:?}"); + h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft); + h.run_steps(3); + let r3 = comments(&h)[0].rect; + assert!((r2[0] - r3[0] - 10.0 * pixel).abs() < 0.01, "ten pixels left: {r3:?} from {r2:?}"); + h.state_mut().undo(); + h.run_steps(2); + assert!((comments(&h)[0].rect[0] - r2[0]).abs() < 0.01, "each press is one undo step"); + // Nothing selected: → turns the page again. + h.key_press(Key::Escape); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.selected, None); + let rect = comments(&h)[0].rect; + h.key_press(Key::ArrowRight); + h.run_steps(3); + assert_eq!(comments(&h)[0].rect, rect, "the comment stays"); + assert_eq!(h.state().views[0].current, 1, "and the view goes to the next page"); +} + #[test] fn a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool() { // #2338: the new shape showed its handles, but only the Select tool answered them. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c12461793..703f1e9d8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -52,7 +52,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Rulers, grids, snap to grid, guides | yes | no | planned | | Cursor coordinates, line weights | yes | no | planned | | Measuring snap to paths, endpoints, midpoints, intersections | yes | yes | shipped | -| Nudge selected objects with arrow keys (Shift for larger steps) | yes | yes in Edit text: a point, Shift ten points, for selected objects, images and an open paragraph's box (#1520); and in Prepare a form, for the selected field (#2608) | shipped | +| Nudge selected objects with arrow keys (Shift for larger steps) | yes | yes in Edit text: a point, Shift ten points, for selected objects, images and an open paragraph's box (#1520); and in Prepare a form, for the selected field (#2608); a selected comment: a screen pixel, ⌘/Ctrl or Shift ten | shipped | | Single-key tool accelerators (H, V, Z, …) | yes, opt-in | no | planned | | Context menus on pages, thumbnails, comments, fields | yes | thumbnails, comments, fields | partial | | Comment pop-ups as movable windows, connector lines | yes | inline in the panel | planned | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa77..d55fe67f6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3971,6 +3971,18 @@ tools = ["comment_delete"] evidence = ["crates/ui-egui/tests/it/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"] notes = "deletes the pop-up and the reply thread" +[[feature]] +id = "comment.keyboard-nudge" +title = "Move the selected comment with the arrow keys" +area = "E" +tier = "P2" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_edit"] +evidence = ["crates/ui-egui/tests/it/comments.rs::arrow_keys_nudge_the_selected_comment"] +notes = "With a comment selected (Select tool, or a Fill & Sign mark picked up), an arrow key moves it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, up being up on screen whatever the zoom and rotation; each press is one undo step, and the arrows scroll and turn pages again once nothing is selected. Text markup stays with its text and a locked comment where it is. Headless, comment_edit's `move` takes the offset in points." + [[feature]] id = "comment.reshape" title = "Change a line's ends and a polygon's corners after drawing" From 667bc697b224d8b96a3557d7d5d5aa6e721f7304 Mon Sep 17 00:00:00 2001 From: Janiz-zzz <267742026+Janiz-zzz@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:47:08 +0300 Subject: [PATCH 12/59] i18n(fi): translate the 64 labels left in English after #2210 Replaces the English placeholders added during the #2210 integration (calculation order, text formatting, print, thumbnails, layers, comment reshape and signature checks, plus the two signature wordings from 3e180944) with Finnish, and updates the fi.tsv sha256 and the entry count in docs/localization.md. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 2 +- ATTRIBUTION.toml | 4 +- crates/ui-egui/src/i18n/fi.tsv | 130 ++++++++++++++++----------------- docs/localization.md | 2 +- 4 files changed, 69 insertions(+), 69 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 593ae6e68..f0de55d67 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -34,7 +34,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3f42a5401..1fcac5f6d 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -299,12 +299,12 @@ sha256 = "00a2153c20a191fbe04f1ce61f140fd9628cfe2f8f75dad5bce8a7406e1bd091" path = "crates/ui-egui/src/i18n/fi.tsv" title = "Finnish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "609efa92054aa76de4bb6062c0fcc0a8b450519f4b253b4c0f395b3cbb34d2ba" +sha256 = "fd9c4035f567f5b495e61ba8422f76b9a50c914811fc80b4ecaa1005e100bd15" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index efcf3e3c6..66808a1cd 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2496,7 +2496,7 @@ animation effects Off Ei käytössä The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Allekirjoittajan varmennetta ei ole myönnetty asiakirjojen allekirjoittamiseen (sen laajennettu avaimen käyttö ei sisällä asiakirjojen allekirjoitusta). The signer's certificate has critical extensions PdfCraft does not recognize ({}). Allekirjoittajan varmenteessa on kriittisiä laajennuksia, joita PdfCraft ei tunnista ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Kohteen {} varmenne ei ole varmenteita myöntävä CA-varmenne, joten sitä ei käytetä kohteen {} vahvistamiseen. - The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Kohteen {} varmenteessa on kriittisiä laajennuksia, joita PdfCraft ei tunnista, joten sitä ei käytetä kohteen {} vahvistamiseen. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Kohteen {} varmenne ei salli näin montaa alempaa CA-varmennetta (polun pituus), joten sitä ei käytetä kohteen {} vahvistamiseen. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Kohteen {} varmenne ei ollut voimassa allekirjoitushetkellä, joten sitä ei käytetä kohteen {} vahvistamiseen. The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Allekirjoittajan henkilöllisyys on tuntematon, koska sitä ei ole lisätty luotettujen varmenteiden luetteloon eikä mikään sen ylemmistä varmenteista ole luotettu varmenne. @@ -2516,7 +2516,7 @@ animation effects Off Ei käytössä The timestamp token could not be read ({}). Aikaleimatunnistetta ei voitu lukea ({}). The document has been altered or corrupted since the timestamp was applied. Asiakirjaa on muutettu tai se on vioittunut aikaleiman lisäämisen jälkeen. The timestamp authority's certificate was not valid at the time of timestamping. Aikaleiman myöntäjän varmenne ei ollut voimassa aikaleimaushetkellä. - The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. + The timestamp authority's certificate is not issued for time stamping. Aikaleiman myöntäjän varmennetta ei ole myönnetty aikaleimaukseen. This document has not been modified since this timestamp was applied. Asiakirjaa ei ole muokattu tämän aikaleiman lisäämisen jälkeen. The document has been modified since this timestamp was applied, but the changes are permitted ({}). Asiakirjaa on muokattu tämän aikaleiman lisäämisen jälkeen, mutta muutokset ovat sallittuja ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). Asiakirjaa on muutettu tämän aikaleiman lisäämisen jälkeen tavoilla, joita se ei salli ({}). @@ -2696,69 +2696,69 @@ theme Appearance Teema # Labels added upstream 2026-10-16…18. the name is not a plain file name nimi ei ole pelkkä tiedostonimi Zoom — type a percentage and press Enter Zoomaus — kirjoita prosentti ja paina Enter -# Untranslated: English text until a translator fills these in. - Calculation order Calculation order - Fix order Fix order - Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. - Earlier in calculation order Earlier in calculation order - Later in calculation order Later in calculation order - Too early Too early - Reads {name}, which calculates later, so it shows an old value. Reads {name}, which calculates later, so it shows an old value. - Loop Loop - Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Reads {names}, which read it back. No order can fix a loop: change one of the formulas. - Not in order Not in order - Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. - Back to Prepare a form Back to Prepare a form - No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. - {names} and {last} {names} and {last} - {field} reads no other field {field} reads no other field - {field} reads {fields} {field} reads {fields} - No field reads it No field reads it - The order saved in the PDF needs tidying The order saved in the PDF needs tidying - Moves only what must move Moves only what must move - Every field calculates after the fields it reads. Every field calculates after the fields it reads. - Every field outside a loop calculates after the fields it reads. Every field outside a loop calculates after the fields it reads. - {field} reads its own value {field} reads its own value - {fields} read each other {fields} read each other - No order can fix a loop. Change one of the formulas. No order can fix a loop. Change one of the formulas. - Edit {field}'s formula Edit {field}'s formula - Order · top calculates first Order · top calculates first - Script Script - Links on page Links on page - Show on the page which fields each field reads Show on the page which fields each field reads - Drag a field to move it, or select it and press {up} or {down}. Drag a field to move it, or select it and press {up} or {down}. - Set calculation order Set calculation order -@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} calculation now uses the new name.|{n} calculations now use the new name. -@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. -@plural {fields} reads it|{fields} read it {fields} reads it|{fields} read it -@plural {n} field shows an old value|{n} fields show old values {n} field shows an old value|{n} fields show old values -@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. -@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} more field calculates too early.|{n} more fields calculate too early. -@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. - Bulleted list Bulleted list - Numbered list Numbered list - Alignment Alignment - Superscript Superscript - Subscript Subscript - Paragraph spacing Paragraph spacing - Less Less - Other options Other options - Always use my default zoom and page layout Always use my default zoom and page layout - Larger thumbnails Larger thumbnails - Layer options Layer options - Printer default Printer default - Reading the printer's settings… Reading the printer's settings… - Reset to Initial Visibility Reset to Initial Visibility - Shrink the page content to make room for the header and footer Shrink the page content to make room for the header and footer - Smaller thumbnails Smaller thumbnails - The document's security settings don't allow printing The document's security settings don't allow printing - Thumbnail size Thumbnail size - Reshape comment Reshape comment - The signature's signed attributes have no content type, which CMS requires; it was read anyway. The signature's signed attributes have no content type, which CMS requires; it was read anyway. - This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on. This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on. - The signature's signed attributes have no message digest, so the signature does not cover the document. The signature's signed attributes have no message digest, so the signature does not cover the document. - The signature's signed attributes name a content type other than data, so it is not a signature of the document. The signature's signed attributes name a content type other than data, so it is not a signature of the document. - Remove {standard} identification Remove {standard} identification +# Calculation order, text formatting, print and signature labels (AI-assisted, not separately native-reviewed) + Calculation order Laskentajärjestys + Fix order Korjaa järjestys + Move only what must move, so every field calculates after the fields it reads. Siirrä vain se, mikä on pakko, jotta jokainen kenttä lasketaan lukemiensa kenttien jälkeen. + Earlier in calculation order Aiemmaksi laskentajärjestyksessä + Later in calculation order Myöhemmäksi laskentajärjestyksessä + Too early Liian aikaisin + Reads {name}, which calculates later, so it shows an old value. Lukee kenttää {name}, joka lasketaan myöhemmin, joten se näyttää vanhan arvon. + Loop Silmukka + Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lukee kenttiä {names}, jotka lukevat sitä takaisin. Mikään järjestys ei korjaa silmukkaa: muuta jotakin kaavoista. + Not in order Ei järjestyksessä + Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Puuttuu PDF:ään tallennetusta laskentajärjestyksestä: muut PDF-katseluohjelmat eivät ehkä laske sitä. + Back to Prepare a form Takaisin kohtaan Valmistele lomake + No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Mikään tämän lomakkeen kenttä ei vielä laske arvoaan. Anna kentälle laskenta sen ominaisuuksien Laskenta-välilehdellä, niin se näkyy tässä. + {names} and {last} {names} ja {last} + {field} reads no other field {field} ei lue muita kenttiä + {field} reads {fields} {field} lukee: {fields} + No field reads it Mikään kenttä ei lue sitä + The order saved in the PDF needs tidying PDF:ään tallennettu järjestys kaipaa siistimistä + Moves only what must move Siirtää vain sen, mikä on pakko + Every field calculates after the fields it reads. Jokainen kenttä lasketaan lukemiensa kenttien jälkeen. + Every field outside a loop calculates after the fields it reads. Jokainen silmukan ulkopuolinen kenttä lasketaan lukemiensa kenttien jälkeen. + {field} reads its own value {field} lukee omaa arvoaan + {fields} read each other {fields} lukevat toisiaan + No order can fix a loop. Change one of the formulas. Mikään järjestys ei korjaa silmukkaa. Muuta jotakin kaavoista. + Edit {field}'s formula Muokkaa kentän {field} kaavaa + Order · top calculates first Järjestys · ylin lasketaan ensin + Script Komentosarja + Links on page Yhteydet sivulla + Show on the page which fields each field reads Näytä sivulla, mitä kenttiä kukin kenttä lukee + Drag a field to move it, or select it and press {up} or {down}. Siirrä kenttää vetämällä tai valitse se ja paina {up} tai {down}. + Set calculation order Aseta laskentajärjestys +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} laskenta käyttää nyt uutta nimeä.|{n} laskentaa käyttää nyt uutta nimeä. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. Laskentajärjestyksessä on {n} kohta, joka ei laske mitään. Korjaa järjestys poistaa sen.|Laskentajärjestyksessä on {n} kohtaa, jotka eivät laske mitään. Korjaa järjestys poistaa ne. +@plural {fields} reads it|{fields} read it {fields} lukee sitä|{fields} lukevat sitä +@plural {n} field shows an old value|{n} fields show old values {n} kenttä näyttää vanhan arvon|{n} kenttää näyttää vanhoja arvoja +@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} lasketaan ennen lukemaansa kenttää {fields}.|{field} lasketaan ennen lukemiaan kenttiä {fields}. +@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} muu kenttä lasketaan liian aikaisin.|{n} muuta kenttää lasketaan liian aikaisin. +@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} puuttuu PDF:ään tallennetusta järjestyksestä, joten muut PDF-katseluohjelmat eivät ehkä laske sitä.|{fields} puuttuvat PDF:ään tallennetusta järjestyksestä, joten muut PDF-katseluohjelmat eivät ehkä laske niitä. + Bulleted list Luettelomerkit + Numbered list Numeroitu luettelo + Alignment Tasaus + Superscript Yläindeksi + Subscript Alaindeksi + Paragraph spacing Kappaleväli + Less Vähemmän + Other options Muut asetukset + Always use my default zoom and page layout Käytä aina oletuszoomaustani ja -sivuasetteluani + Larger thumbnails Suuremmat pikkukuvat + Layer options Tasoasetukset + Printer default Tulostimen oletus + Reading the printer's settings… Luetaan tulostimen asetuksia… + Reset to Initial Visibility Palauta alkuperäinen näkyvyys + Shrink the page content to make room for the header and footer Pienennä sivun sisältöä, jotta ylä- ja alatunnisteelle jää tilaa + Smaller thumbnails Pienemmät pikkukuvat + The document's security settings don't allow printing Asiakirjan suojausasetukset eivät salli tulostamista + Thumbnail size Pikkukuvan koko + Reshape comment Muuta kommentin muotoa + The signature's signed attributes have no content type, which CMS requires; it was read anyway. Allekirjoituksen allekirjoitetuista määritteistä puuttuu sisältötyyppi, jota CMS edellyttää; se luettiin silti. + This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on. Tämä allekirjoitus käyttää alle 2048-bittistä RSA-avainta, jota ei enää pidetä riittävän vahvana allekirjoituksiin. Se tarkistuu yhä, mutta siihen ei pidä luottaa. + The signature's signed attributes have no message digest, so the signature does not cover the document. Allekirjoituksen allekirjoitetuista määritteistä puuttuu viestitiiviste, joten allekirjoitus ei kata asiakirjaa. + The signature's signed attributes name a content type other than data, so it is not a signature of the document. Allekirjoituksen allekirjoitetut määritteet nimeävät muun sisältötyypin kuin data, joten se ei ole asiakirjan allekirjoitus. + Remove {standard} identification Poista {standard}-tunniste # Prepare a form ▸ Text field table (AI-assisted, not separately native-reviewed) Add text field table Lisää tekstikenttätaulukko diff --git a/docs/localization.md b/docs/localization.md index ccacccd09..b5e78ece1 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -24,7 +24,7 @@ The Lithuanian catalog has 2,585 entries and three integer plural forms. Calenda The Vietnamese catalog has 2,674 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). Coverage tests require every current command, UI literal, bundled key and credit label. A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. -The Finnish catalog has 2,674 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. +The Finnish catalog has 2,762 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. The Catalan catalog has 2,592 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation. From 5856c12eb3748ff1e1be936695aa8014896a08c2 Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:55:44 -0300 Subject: [PATCH 13/59] fix(sign): judge a page's indirect /Annots array by the annotations it gains A page whose /Annots is an object of its own keeps its dictionary when a later revision adds a signature widget or a comment: only the array changes. That array fell through to "other changes", so countersigning such a document (as many signing services do) turned every earlier signature invalid, including PdfCraft's own second signature. A changed array that was a signed page's /Annots is now classified like an inline /Annots: by the annotations it gained (signature, form fill or comments), under the same DocMDP rules. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/sign/src/pdf.rs | 12 +++++++++++ crates/sign/tests/it/pdf.rs | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 82a801dee..cd555a989 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1265,6 +1265,11 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod // attacker-written version carries (relabelling a signed page /Type /Sig, or dropping its // /Type and adding /ModDate, must not reclassify it away from page content). let signed_pages: HashSet = pdfcraft_annot::page_refs(&old).unwrap_or_default().into_iter().collect(); + // The signed pages' /Annots arrays that are objects of their own. Adding an annotation updates + // such an array in place (the page dictionary does not change), so it is judged like a + // page's inline /Annots: by the annotations it gained. + let signed_annots: HashSet = + signed_pages.iter().filter_map(|pg| old.get(*pg).as_dict().and_then(|d| d.get(b"Annots").and_then(Object::as_ref))).collect(); // What each signed page draws from, by the role it held when signed (#1787). let signed_resources = signed_page_resources(&old, &signed_pages); let old_info = old.trailer().get(b"Info").and_then(Object::as_ref); @@ -1333,6 +1338,13 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod (Some(k), _) => k, // The Document Security Store's own arrays and dictionaries, when they only grew. (_, _) if dss.contains(&num) => kind::SECURITY_STORE, + // A signed page's indirect /Annots array, still an array: what it gained. + (_, Object::Array(now)) if signed_annots.contains(&r) && before.as_deref().is_some_and(|b| b.as_array().is_some()) => { + let was: HashSet = + before.as_deref().and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_ref).collect()).unwrap_or_default(); + let added: Vec = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect(); + added_annots(doc, &added) + } (_, Object::Dict(d)) => { let was = before.as_deref().and_then(Object::as_dict); if signed_pages.contains(&r) { diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 61517b53d..cd0b713a8 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -139,6 +139,47 @@ fn signing_an_existing_field_and_counter_signing() { )); } +/// A page whose /Annots is an object of its own: signing and commenting update that array in +/// place, not the page. A second signature is a signature change for the first, as with an +/// inline /Annots, and a comment is still a comment (refused under a form-fill certification). +#[test] +fn an_indirect_annots_array_is_judged_by_the_annotations_it_gains() { + let page = pdfcraft_cos::ObjRef { num: 3, generation: 0 }; + let unsigned = edit_after(&fixture(), |doc| { + let annots = doc.add(Object::Array(vec![Object::Ref(pdfcraft_cos::ObjRef { num: 6, generation: 0 })])); + doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Ref(annots))).unwrap(); + }); + let annots_ref = |bytes: &[u8]| open(bytes).get(page).as_dict().and_then(|d| d.get(b"Annots").and_then(Object::as_ref)); + let annots = annots_ref(&unsigned).unwrap(); + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let first = pdfcraft_sign::sign(&open(&unsigned), &id, &opts()).unwrap(); + let second = pdfcraft_sign::sign(&open(&first), &id, &SignOptions { rect: Some([20.0, 160.0, 220.0, 210.0]), ..opts() }).unwrap(); + assert_eq!(annots_ref(&second), Some(annots), "the signer kept the page's array indirect"); + let doc = open(&second); + let sigs = signatures(&doc, &second, &trust); + let s = sigs.iter().find(|s| s.field == "Signature1").unwrap(); + assert_eq!(s.modification, Modification::Allowed(vec![kind::SIGNATURE]), "{:?}", s.details); + assert_eq!(s.status, Status::Valid); + // A comment appended to the indirect array. + let comment = |doc: &mut Document| { + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Text")); + d.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(10), Object::Int(10), Object::Int(30), Object::Int(30)])); + let r = doc.add(Object::Dict(d)); + let mut a = doc.get(annots).as_array().cloned().unwrap(); + a.push(Object::Ref(r)); + doc.set(annots, Object::Array(a)); + }; + let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap(); + let s = check(&edit_after(&first, comment)); + assert_eq!((s.status, s.modification), (Status::Valid, Modification::Allowed(vec![kind::COMMENTS]))); + let certified = pdfcraft_sign::sign(&open(&unsigned), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap(); + let s = check(&edit_after(&certified, comment)); + assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS]))); +} + /// Issue #1328: a signature field whose /V is null, directly or through a reference, is unsigned: /// it is listed as unsigned and can be signed by name. #[test] From 4ac5165a8671cbf37283ebd41faeabdc6a3e066b Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:57:31 -0300 Subject: [PATCH 14/59] fix(sign): let an LTV update declare the ESIC extension in the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PAdES LTV writers add the /DSS and, in the same revision, declare the ESIC developer extension in the catalog's /Extensions (ISO 32000-2 §7.12). Any catalog change outside a short key list was "document structure", so the signature such an update protects showed as invalid. /Extensions may now gain declarations after signing; one that was signed may still not change or go. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/sign/src/pdf.rs | 18 +++++++++++++++++- crates/sign/tests/it/pdf.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 82a801dee..1e03c6ff3 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1685,6 +1685,18 @@ fn page_change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Di } } +/// Whether the catalog `d` keeps every developer extension the signed catalog `b` declared, each +/// with the same value: it may only declare more. +fn extensions_kept(doc: &Document, old: &Document, d: &Dict, b: &Dict) -> bool { + let now = d.get(b"Extensions").map(|e| doc.resolve(e)).and_then(|e| e.as_dict().cloned()); + let was = b.get(b"Extensions").map(|e| old.resolve(e)).and_then(|e| e.as_dict().cloned()); + match (was, now) { + (None, _) => true, + (Some(was), Some(now)) => was.iter().all(|(k, v)| now.get(k) == Some(v)), + (Some(_), None) => false, + } +} + fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) -> &'static str { let ty = d.name(b"Type"); // A later revision cannot make an existing object a signature by relabelling it: a signature @@ -1694,9 +1706,13 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) // Only a new signature value or policy: rewriting a signed one is not a signature (#1788). Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") if before.is_none() => return kind::SIGNATURE, Some(b"Catalog") => { - let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering"]; + let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering", b"Extensions"]; return match before { Some(b) if without(d, keys) != without(b, keys) => kind::STRUCTURE, + // Developer extensions (ISO 32000-2 §7.12) only declare what the file uses. A + // writer adding a /DSS also declares the ESIC extension it belongs to, so a + // declaration may be added, but none that was signed may change or go. + Some(b) if !extensions_kept(doc, old, d, b) => kind::STRUCTURE, Some(b) => { if d.get(b"AcroForm") != b.get(b"AcroForm") { // Compare the forms themselves, inline or not. diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 61517b53d..0b0f91654 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -544,6 +544,35 @@ fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() { assert_eq!(check(&doc, &tampered, &trusted).status, Status::Invalid); } +#[test] +fn ltv_writers_may_declare_the_esic_extension_but_not_change_a_signed_one() { + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap(); + let ext = |base: &[u8], level: i64| { + let mut esic = pdfcraft_cos::Dict::new(); + esic.set(b"BaseVersion".to_vec(), Object::name("1.7")); + esic.set(b"ExtensionLevel".to_vec(), Object::Int(level)); + let mut e = pdfcraft_cos::Dict::new(); + e.set(b"ESIC".to_vec(), Object::Dict(esic)); + edit_after(base, |doc| { + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(e))).unwrap(); + }) + }; + // As PAdES LTV writers save it: the store and the ESIC declaration in one revision. + let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); + let evidence = pdfcraft_sign::dss::Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }; + let ltv = pdfcraft_sign::dss::embed(&open(&ext(&signed, 2)), &evidence).unwrap(); + let s = check(<v); + assert_eq!(s.status, Status::Valid, "{:?}", s.details); + assert!(matches!(&s.modification, Modification::Allowed(k) if !k.contains(&kind::STRUCTURE)), "{:?}", s.modification); + // A declaration that was signed may not change afterwards. + let signed_with = pdfcraft_sign::sign(&open(&ext(&fixture(), 2)), &id, &opts()).unwrap(); + let s = check(&ext(&signed_with, 3)); + assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::STRUCTURE]))); +} + #[test] fn embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid() { use pdfcraft_sign::dss::{self, Evidence}; From c829a1ad44cb4128d68b71895238ae1e8941219d Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:03:37 -0300 Subject: [PATCH 15/59] fix(sign): a removed annotation is not hidden behind an added signature Review follow-up: an indirect /Annots array that drops a signed annotation while gaining a signature widget was judged by the widget alone, so a form-fill certification let a signed comment be deleted. A removed comment is now a comment change, and a removed widget is a change no permission covers. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/sign/src/pdf.rs | 17 ++++++++++++++--- crates/sign/tests/it/pdf.rs | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index cd555a989..0cd58d7f9 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1338,12 +1338,23 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod (Some(k), _) => k, // The Document Security Store's own arrays and dictionaries, when they only grew. (_, _) if dss.contains(&num) => kind::SECURITY_STORE, - // A signed page's indirect /Annots array, still an array: what it gained. + // A signed page's indirect /Annots array, still an array: what it lost, then what it + // gained. Dropping a signed annotation is never hidden behind an added signature. (_, Object::Array(now)) if signed_annots.contains(&r) && before.as_deref().is_some_and(|b| b.as_array().is_some()) => { let was: HashSet = before.as_deref().and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_ref).collect()).unwrap_or_default(); - let added: Vec = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect(); - added_annots(doc, &added) + let kept: HashSet = now.iter().filter_map(Object::as_ref).collect(); + let removed: Vec = was.iter().filter(|r| !kept.contains(r)).copied().collect(); + if removed.is_empty() { + let added: Vec = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect(); + added_annots(doc, &added) + } else if removed.iter().any(|r| old.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))) { + // A form field or signature widget taken off the page: no permission covers it. + kind::OTHER + } else { + // Deleting comments is a comment change (permitted at P 3 only). + kind::COMMENTS + } } (_, Object::Dict(d)) => { let was = before.as_deref().and_then(Object::as_dict); diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index cd0b713a8..b3a025fc2 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -178,6 +178,30 @@ fn an_indirect_annots_array_is_judged_by_the_annotations_it_gains() { let certified = pdfcraft_sign::sign(&open(&unsigned), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap(); let s = check(&edit_after(&certified, comment)); assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS]))); + // Under that certification, a signed comment swapped for a new signature widget is still a + // deleted comment, and a form field taken off the page is permitted by no level. + let commented = pdfcraft_sign::sign(&open(&edit_after(&unsigned, comment)), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap(); + let swap = |drop: &dyn Fn(&Document, &Object) -> bool| { + edit_after(&commented, |doc| { + let mut w = pdfcraft_cos::Dict::new(); + w.set(b"Type".to_vec(), Object::name("Annot")); + w.set(b"Subtype".to_vec(), Object::name("Widget")); + w.set(b"FT".to_vec(), Object::name("Sig")); + w.set(b"T".to_vec(), PdfString::text("Later")); + w.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0); 4])); + let w = doc.add(Object::Dict(w)); + let mut a = doc.get(annots).as_array().cloned().unwrap(); + a.retain(|o| !drop(doc, o)); + a.push(Object::Ref(w)); + doc.set(annots, Object::Array(a)); + }) + }; + let subtype = + |doc: &Document, o: &Object, t: &[u8]| o.as_ref().is_some_and(|r| doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(t))); + let s = check(&swap(&|doc, o| subtype(doc, o, b"Text"))); + assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS]))); + let s = check(&swap(&|doc, o| subtype(doc, o, b"Widget") && o.as_ref().is_some_and(|r| r.num == 6))); + assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::OTHER]))); } /// Issue #1328: a signature field whose /V is null, directly or through a reference, is unsigned: From 60ce45f9badce7ad655a70537fe082cb82a1d792 Mon Sep 17 00:00:00 2001 From: Oliver Hagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:08:35 +0200 Subject: [PATCH 16/59] Flatten comments from the page's right-click menu and the Comments panel's menu Reviewers flatten their comments and Fill & Sign signature once done, so nothing on the signed or reviewed pages moves any more. The command was only in All tools and the palette; it now also ends the page's right-click menu (with or without a comment selected, while the document has comments) and the Comments panel's More options menu. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/comments.rs | 20 +++++++++++++- crates/ui-egui/src/comments_panel.rs | 6 ++++ crates/ui-egui/tests/it/comments.rs | 41 ++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 5 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8df86fb3..2e37f9638 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4031,7 +4031,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.separator(); } } - canvas_action = comments::context_menu(ui, view, info, prefs, allowed).or(copy_image); + canvas_action = comments::context_menu(ui, view, info, prefs, allowed, can_modify).or(copy_image); }); (wanted, visible_now) }); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index d80f988b7..92d1d6a6d 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1841,7 +1841,15 @@ pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &Doc } /// Items of the canvas context menu for the selected comment (or the page). -pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option { +/// `can_modify`: the document's pages may change (Flatten comments draws into them). +pub(crate) fn context_menu( + ui: &mut egui::Ui, + view: &mut DocView, + info: &DocInfo, + prefs: &CommentPrefs, + allowed: bool, + can_modify: bool, +) -> Option { let mut action = None; let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && a.in_reply_to.is_none())); match selected { @@ -1925,6 +1933,16 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo } } } + // Every comment of the document into its pages, as Comment ▸ Flatten comments does: signed + // or reviewed pages whose comments should no longer move. + if !info.annotations.is_empty() { + ui.separator(); + if ui.add_enabled(can_modify, egui::Button::new(tl!("Flatten comments"))).clicked() { + view.comments.selected = None; + action = Some(CanvasAction::Edit(Box::new(Edit::Flatten { comments: true, fields: false }))); + ui.close(); + } + } action } diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index ff8570456..6247021a9 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -585,6 +585,12 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV ui.close(); } } + // Into the pages, for good once saved: apart from the list's other commands. + ui.separator(); + if ui.button(tl!("Flatten comments")).clicked() { + command = Some("comment.flatten"); + ui.close(); + } }); let filter = icons::button(ui, "filter", 26.0, active, tl!("Filter comments")); egui::Popup::menu(&filter).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6194c7516..57bcc2c59 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1265,6 +1265,47 @@ fn page_groups_collapse_and_expand() { assert!(shown(&h, "First page note") && shown(&h, "Second page note"), "every page is open again"); } +/// Flatten comments is in the page's right-click menu and in the Comments panel's "…" menu: the +/// comments go into the page, one undo step brings them back. +#[test] +fn flatten_comments_from_the_page_menu_and_the_panel_menu() { + let mut h = harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "select").unwrap(); + }); + let shape = pdfcraft_engine::Shape::Rectangle { rect: [20.0, 20.0, 80.0, 60.0] }; + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: "Reviewed".into(), + author: "Tester".into(), + })); + h.state_mut().views[0].comments.selected = None; + settle(&mut h); + assert_eq!(comments(&h).len(), 1); + + // Right-click on the page, away from the comment. + let p = at(&h, 200.0, 150.0); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.get_by_label("Flatten comments").click(); + h.run_steps(4); + assert!(comments(&h).is_empty(), "the comment went into the page"); + h.state_mut().undo(); + h.run_steps(3); + assert_eq!(comments(&h).len(), 1, "one undo step brings it back"); + + h.get_by_label("More options").click(); + h.run_steps(2); + h.get_by_label("Flatten comments").click(); + h.run_steps(4); + assert!(comments(&h).is_empty(), "the panel's menu flattens too"); +} + #[test] fn comments_take_checkmarks_lock_hide_and_summarize() { let mut h = harness(|app| { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 362c2aa77..b0b81f81b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4232,8 +4232,8 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/it/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments"] -notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)" +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/it/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments", "crates/ui-egui/tests/it/comments.rs::flatten_comments_from_the_page_menu_and_the_panel_menu"] +notes = "also in the page's right-click menu and the Comments panel's More options menu; appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)" [[feature]] id = "comment.migrate" From 31828efd6ecb2179fd11e2edf4fb7868333c1f45 Mon Sep 17 00:00:00 2001 From: Lombardi2003 Date: Thu, 22 Oct 2026 17:43:39 +0200 Subject: [PATCH 17/59] feat(ui): the secured-document notice can be turned off for good (#4035) The notice that a document is secured came back on every open; Dismiss lasted only for that view. Its new "Don't show again" turns it off in every document, kept in the settings as show_secured_notice and turned back on in Preferences (or --secured-notice). Only the notice changes: the restrictions, the encryption and Document Properties > Security stay as they are. The two labels are translated in all 33 catalogues, whose ATTRIBUTION hashes and provenance notes are refreshed. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 66 +++++++------- ATTRIBUTION.toml | 132 ++++++++++++++-------------- crates/ui-egui/src/canvas.rs | 18 +++- crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/ckb.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/kk.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/lo.tsv | 2 + crates/ui-egui/src/i18n/lt.tsv | 2 + crates/ui-egui/src/i18n/nb.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ro.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sl.tsv | 2 + crates/ui-egui/src/i18n/sq.tsv | 2 + crates/ui-egui/src/i18n/te.tsv | 2 + crates/ui-egui/src/i18n/th.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/js_ui.rs | 2 + crates/ui-egui/src/lib.rs | 16 ++++ crates/ui-egui/tests/it/editing.rs | 55 ++++++++++++ parity/acrobat-features.toml | 4 +- 40 files changed, 256 insertions(+), 103 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 593ae6e68..01668750f 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -8,33 +8,33 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| -| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | @@ -285,17 +285,17 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | | `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format | | `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | -| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | ## Compiled in through dependencies (5) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3f42a5401..91a6b8d5e 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2,12 +2,12 @@ path = "crates/ui-egui/src/i18n/el.tsv" title = "Greek interface translation catalog" author = "Camthalion77 and contributors" -source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1902f1c31698e6adfdaf65b35b1fe1628f5cd2bd0182dd3b99bcf5e21720f394" +sha256 = "9936db8d856180cbf99c900045f473eccf32682888f114f8cccc715fa1ac741e" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -25,286 +25,286 @@ sha256 = "1902f1c31698e6adfdaf65b35b1fe1628f5cd2bd0182dd3b99bcf5e21720f394" path = "crates/ui-egui/src/i18n/zh-hans.tsv" title = "Simplified Chinese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2e23c41fcd5b9118cfde03b3a45aa2f988f7eeba650677b6f1f6664e543c7a59" +sha256 = "5dcad6a1d74310ee039c6c1374094282dbddaff74eac3c4c38520bf1e829abc5" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" title = "Traditional Chinese (Taiwan) interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "105703f2fbc07c0663308ebd6852199de12a5851a37507d7e51e477eb5e57218" +sha256 = "c0283fc45e66d3d3dfbba9bd29a2e849fc989864be9fecd647cc900767174f18" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" title = "Czech interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b08f8282923d2faaf1f658d2aff8f71fe5f2d3a33e792eeb50fb0e2b396ef24f" +sha256 = "49bdbac55d0f4b7e510f49e0f434bccfae47dbae7beae98dd61f72a1333a6fb6" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" title = "Brazilian Portuguese interface translation catalog" author = "André Cardoso (andrecards), PdfCraft contributors" -source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "df17dada3df736e75bd9b03e5ecceec164cce63767e33a1efdb98c56a121c890" +sha256 = "2040e2b5409d5793ace20ec490d501459a8f37371806d1942402c8504a61b9c7" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" title = "Spanish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8e8c0cfda0886c584e5286370ec00814206c3af3699c76665e636fa6fdb6310b" +sha256 = "d4b2cd5868951d066e6e1ba1d3041ba22c89727ee643cae4b68c1d523d048f76" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" title = "French interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3f31e5c9abdb0208d48d8c68aa81437d9d1e10aeb615dedf811fefa539d93279" +sha256 = "ce62deb099dcac4c776a4344e5c9ae245729252f5ae03ba17b0ca00d9f39bea1" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" title = "German interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "815a3a672421cc3590fb1f267755316659cdb2046e30bbb3ad8b31e96f08b20a" +sha256 = "4910828b94dff642224e15213af17e6347904b1bb07c94f29b322df866ac0b0a" [[asset]] path = "crates/ui-egui/src/i18n/lo.tsv" title = "Lao interface translation catalog" author = "@bounkirdni-2025" -source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6bd67286795ed487eedf2ce549249c8a83286cb4273490c1349996050b540fed" +sha256 = "7b83ab2d16ad490ef96f278e0cad6e88c135bf2c84cff0b7e1afb0af8dfa797f" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" title = "Telugu interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9cd63425705fb9a3aa029ff3a5110d0877a34f9038f1d64a2102ccd6c3cb35a6" +sha256 = "fe6f2573868155342119258c82b1ca1c544c0069b01910e99428333fe975aa88" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" title = "Italian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4" +sha256 = "4fcee87fa4aec634a97a38f80d1ae1a8ec9ccaa4bd36e23009384eff521b5d5e" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" title = "Polish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0bd75a93f8d18deb832223bd0642cc4f4abde04a322b684a143a9d4b4687a344" +sha256 = "1883f5f41cded31feb9662fcd3b145502bea90f129dd52447a287ac9d58fe202" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" title = "Russian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3d539a28afcf8c2e36b7087ac23334b50bac7971e03da20a62b6e7e400763a87" +sha256 = "720d53384dd9f4395555b2237d8789aa3ada42bbe13aef28dabeac9e9ee18cdc" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" title = "Hungarian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "71b7e8180fc759aebad4660953ab8b55383def0572c1ace2288cd8bfeb1b82dc" +sha256 = "1991108c6721733484caab092af73f19fa41851cf5621c571f465e0168177c6c" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" title = "Bulgarian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e689fb24a8acfa7e85f0f60644f7e2da1225595e6cd6a94109fbe86082926244" +sha256 = "f86a42215104d0e67acae28d7243f5f27044050b5f3f098d70625f0804e59465" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" title = "Kazakh interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c601ea117ec2a3cdba606fdc9a62220fcffbd0c20c1133bacb653a979cf046a5" +sha256 = "a6437d5314bee94148b3e41fc37f44cf442ab28427fb7f103e3db0b735185081" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" title = "Japanese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "006867b891ae2f995c7a711e55b6401db29d20ab493889c6284dc3671f9faa2d" +sha256 = "534e0b4c6cd7301de1a5fb9bd64370617680dd43a9164011e370d0132f20f2a3" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" title = "Ukrainian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9e5b18a09c209525cf08ef53a242ed457cfa346295eef7facde482a75cf72f52" +sha256 = "b61e855509fdb5541923a9af81b5d226448e1b16f35bd4f944fb8a5667286291" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" title = "Arabic interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2306dbbe6a73671fd20df116adb159a7f66c85fbbd1bae5ecef5f6e882d6b7f1" +sha256 = "ac2ec915beaec3623147c892659f350facc828fb635ef135e94b3f5c9ee18e22" [[asset]] path = "crates/ui-egui/src/i18n/ckb.tsv" title = "Central Kurdish (Sorani) interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "864db8aa0a5e443c92e7e388f89349eb0f0e7beda35a0b81695d864dcec51b72" +sha256 = "9b0cc7a1f34581eb93b50378ab2391b129631cdb54482cf8df36464529280d32" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" title = "Turkish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "463a2d28bd5c5ac8df31a5b5c68400d7a61ce51237a58d9337078c973e6f9e3d" +sha256 = "cf7a40ea2dca1c629a147a7bb44d4298bf3785e756133b0531f827de63496263" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" title = "Lithuanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bbef7f4e16f83e9e0116278e7a0a175bad79757f96a18dc639516572533f6d5b" +sha256 = "c21c9241e404692ce924db063b3fcd1200f3e9fd8156f90cfdc9a3669e5fe11b" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" title = "Hindi interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "09d2840c02b40a6049b86c2cb68151d863913e8c91c22226f25d07496c6366aa" +sha256 = "449cef4ff4562ec9f4307c0bb58cd2d4cb11ed08fd605e44018b1f2867fdef60" [[asset]] path = "crates/ui-egui/src/i18n/id.tsv" title = "Indonesian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0dc1abdb262e4568f19ffb2233bfeb3686ae42df8a8d8bec2951351a50e6a886" +sha256 = "0db268f03975057cef68ffd21154be3943c7446fbfdfcfb9a050471bbb78ac91" [[asset]] path = "crates/ui-egui/src/i18n/ko.tsv" title = "Korean interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3e67cee4f3fff776e816523f810b2bb6353235dbdf85181c3f12896604a3339" +sha256 = "76d6bab93760f1e269ff5ae07116bc37af0e797ce1d0d1f568b54a9bf20999f9" [[asset]] path = "crates/ui-egui/src/i18n/ro.tsv" title = "Romanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "00a2153c20a191fbe04f1ce61f140fd9628cfe2f8f75dad5bce8a7406e1bd091" +sha256 = "35dcfa9325501e863ed199e09929f47fdcbd29a8bdff7452ceebc457d85a36f8" [[asset]] path = "crates/ui-egui/src/i18n/fi.tsv" title = "Finnish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "609efa92054aa76de4bb6062c0fcc0a8b450519f4b253b4c0f395b3cbb34d2ba" +sha256 = "9c1a32c22fa53dddb2cfae4239bddc87e2340fc00eabc965e83bdc523b782fa7" [[asset]] path = "assets/icons/accessibility.svg" @@ -3421,23 +3421,23 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" path = "crates/ui-egui/src/i18n/vi.tsv" title = "Vietnamese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "57f0dd007ad16518fbc87d8aeac840ef6d86412bb460aff9fb17b176408fb7a8" +sha256 = "3c8f9f3a90d320cf51459638111316d7e4bd3edba8bb55e68ea3177772e3ae2d" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" title = "Slovene interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3b8351f0da80c2d394221dbe5d62e794bb019c74c2adfc5d933d999f7d3236f" +sha256 = "f6173f8325e229b5e9834e4d9994a0b18b246b4aa1faa1f5c844d6feff9ad993" [[asset]] @@ -3455,12 +3455,12 @@ sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" path = "crates/ui-egui/src/i18n/nb.tsv" title = "Norwegian Bokmål interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "03477c4ab2d0d52543cb91b3aec6f27b2689e6b9bb7c95bbb8463664c75a9dc0" +sha256 = "883feeec906790d1a7a9533dbc670103abe3f2a092ee7d0ab32dcbcfe68dfd5c" [[asset]] path = "assets/icons/superscript.svg" @@ -3510,31 +3510,31 @@ sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" path = "crates/ui-egui/src/i18n/sq.tsv" title = "Albanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ebd177262d8fe20f960f06df084f8a03c9504bcd9990dded3a006e35bd8ef7c9" +sha256 = "29b662169e44fa704389ab0d03fc6682d3c03908cfed429080e74a71f237f99a" [[asset]] path = "crates/ui-egui/src/i18n/th.tsv" title = "Thai interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1dbb66c05af0aec06d5a6fa154e89f1684203dde32c8e8aedd9aeec6c2d5ae02" +sha256 = "f5c1f2a85ae52573f73aae5a64291d42ead8d944ec3d6648663b1d921a8428cb" [[asset]] path = "crates/ui-egui/src/i18n/ca.tsv" title = "Catalan interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5879ee3540ffb630fc83d0f2cb293cc6cbf2e081ea4577161a770794e8cbd22c" +sha256 = "0d834abc851884648c46ab26b89712e69092b17ec70b8459fc0b87aa9b36272f" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8df86fb3..45265fe9d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2657,9 +2657,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let editing_content = (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); let text_style = app.text_style.clone(); + let show_secured_notice = app.show_secured_notice; let view = &mut app.views[index]; - // Opened without the owner password and something is restricted. - let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); + // Opened without the owner password and something is restricted. The notice says so unless + // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸ + // Security, stay as they are. + let secured = show_secured_notice && doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); let repaired = !doc.repair_log().is_empty(); match notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) { #[cfg(not(target_arch = "wasm32"))] @@ -2679,6 +2682,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), Some(Notice::FieldHighlights(on)) => app.view_defaults.highlight_fields = on, Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), + // Back on in Preferences ▸ General. + Some(Notice::HideSecured) => app.show_secured_notice = false, None => {} } // No dialog, close prompt or palette over the page: only then does page input count. @@ -4754,6 +4759,8 @@ enum Notice { Unlock, /// Close this historical snapshot tab (View signed version / View revision). Close, + /// Stop showing the secured-document notice, in every document (#4035). + HideSecured, } /// The notice bar above the pages: a file changed on disk first, then what a historical @@ -4831,6 +4838,7 @@ fn notices( } let mut open_security = false; let mut unlock = false; + let mut hide_secured = false; let mut open_repairs = false; let mut open_console = false; let mut toggled = None; @@ -4912,6 +4920,9 @@ fn notices( if secured && doc.can_unlock() && crate::widgets::pill_button(ui, tl!("Unlock…"), true).clicked() { unlock = true; } + if secured && crate::widgets::pill_button(ui, tl!("Don't show again"), false).clicked() { + hide_secured = true; + } if repaired && !secured && info.fields.is_empty() && crate::widgets::pill_button(ui, tl!("Details"), false).clicked() { open_repairs = true; } @@ -4925,6 +4936,9 @@ fn notices( if unlock { return Some(Notice::Unlock); } + if hide_secured { + return Some(Notice::HideSecured); + } open_security.then_some(Notice::Security).or(toggled) } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 765bbba55..0ee83e33d 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2340,6 +2340,8 @@ key Space مسافة Printer paper and scaling may differ. Check driver settings. قد يختلف ورق الطابعة ومقياس الطباعة. تحقق من إعدادات برنامج التشغيل. Drawing scale changes. Use Actual size for full-scale plans. يتغير مقياس الرسم. استخدم الحجم الفعلي لطباعة المخططات بمقياسها الأصلي. Reload documents when their files change on disk إعادة تحميل المستندات عند تغيّر ملفاتها على القرص + Don't show again عدم الإظهار مرة أخرى + Show a notice when a document is secured إظهار إشعار عندما يكون المستند مؤمَّنًا Always use my default zoom and page layout استخدام التكبير وتخطيط الصفحة الافتراضيين دائمًا This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. تغيّر هذا الملف على القرص. «إعادة التحميل» تعرض النسخة الجديدة وتُسقط تغييراتك غير المحفوظة؛ و«حفظ» يستبدلها بهذه النسخة. Reload إعادة تحميل diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9bb377a93..c02411ea5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2282,6 +2282,8 @@ key Space Интервал Printer paper and scaling may differ. Check driver settings. Хартията и мащабът на принтера може да се различават. Проверете настройките на драйвера. Drawing scale changes. Use Actual size for full-scale plans. Мащабът на чертежа се променя. Използвайте Действителен размер за планове в оригинален мащаб. Reload documents when their files change on disk Презареждай документите, когато файловете им се променят на диска + Don't show again Не показвай отново + Show a notice when a document is secured Показвай известие, когато документът е защитен Always use my default zoom and page layout Винаги използвай моя мащаб и оформление на страниците по подразбиране This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Този файл беше променен на диска. „Презареждане“ показва новата версия и отхвърля незаписаните ви промени; „Запис“ я заменя с тази версия. Reload Презареди diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index dd2f497b8..550029169 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2137,6 +2137,8 @@ action wizard Start Inicia Home / End Inici / Fi H / Space (hold) H / Espai (mantén premut) Reload documents when their files change on disk Torna a carregar els documents quan els seus fitxers canviïn al disc + Don't show again No ho tornis a mostrar + Show a notice when a document is secured Mostra un avís quan un document està protegit This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Aquest fitxer ha canviat al disc. Torna'l a carregar per veure'n la versió nova; es perdran els canvis sense desar. Reload Torna a carregar Couldn't reload {name}: {e} No s'ha pogut tornar a carregar {name}: {e} diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 804e7a97a..0e3052789 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2303,6 +2303,8 @@ key Space بۆشایی Printer paper and scaling may differ. Check driver settings. ڕەنگە کاغەز و پێوەری چاپکەر جیاواز بن. ڕێکخستنەکانی درایڤەر بپشکنە. Drawing scale changes. Use Actual size for full-scale plans. پێوەری وێنەکێشان دەگۆڕێت. «قەبارەی ڕاستەقینە» بەکاربهێنە بۆ پلانەکان بە پێوەری تەواو. Reload documents when their files change on disk دووبارە بارکردنی بەڵگەنامەکان کاتێک فایلەکانیان لەسەر دیسک دەگۆڕێن + Don't show again جارێکی تر پیشان مەدە + Show a notice when a document is secured پیشاندانی ئاگادارکردنەوە کاتێک بەڵگەنامەیەک پارێزراوە This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. ئەم فایلە لەسەر دیسک گۆڕاوە. «دووبارە بارکردن» وەشانی نوێ پیشان دەدات و گۆڕانکارییە پاشەکەوتنەکراوەکانت فڕێدەدات؛ «پاشەکەوتکردن» بەم وەشانە جێگەی دەگرێتەوە. Reload دووبارە بارکردن Couldn't reload {name}: {e} دووبارە بارکردنی {name} نەکرا: {e} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 66cac29aa..236a4d36e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -140,6 +140,8 @@ Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Používá se, když PDF nepožaduje rozložení ani zvětšení. Plynulé posouvání mezi stránkami neskáče. Reopen the files that were open when PdfCraft last closed Znovu otevřít soubory, které byly otevřené při posledním zavření aplikace PdfCraft Reload documents when their files change on disk Znovu načítat dokumenty při změně jejich souborů na disku + Don't show again Znovu nezobrazovat + Show a notice when a document is secured Zobrazit upozornění, když je dokument zabezpečený Always use my default zoom and page layout Vždy používat mé výchozí zvětšení a rozvržení stránky This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Tento soubor se na disku změnil. Znovu načíst zobrazí novou verzi a zahodí neuložené změny; Uložit ji nahradí touto verzí. Reload Znovu načíst diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 76366d452..9a08bd7e6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2324,6 +2324,8 @@ key Space Leertaste Printer paper and scaling may differ. Check driver settings. Druckerpapier und Skalierung können abweichen. Treibereinstellungen prüfen. Drawing scale changes. Use Actual size for full-scale plans. Der Zeichnungsmaßstab ändert sich. Für maßstabsgetreue Pläne Tatsächliche Größe verwenden. Reload documents when their files change on disk Dokumente neu laden, wenn sich ihre Dateien auf dem Datenträger ändern + Don't show again Nicht mehr anzeigen + Show a notice when a document is secured Hinweis anzeigen, wenn ein Dokument geschützt ist Always use my default zoom and page layout Immer meinen Standardzoom und mein Standard-Seitenlayout verwenden This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Diese Datei wurde auf dem Datenträger geändert. „Neu laden“ zeigt die neue Version und verwirft Ihre ungespeicherten Änderungen; „Speichern“ ersetzt sie durch diese Version. Reload Neu laden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 18464664a..e2ac11ea2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2328,6 +2328,8 @@ comment menu Edit Επεξεργασία Printer paper and scaling may differ. Check driver settings. Το χαρτί και η κλίμακα του εκτυπωτή μπορεί να διαφέρουν. Ελέγξτε τις ρυθμίσεις του προγράμματος οδήγησης. Drawing scale changes. Use Actual size for full-scale plans. Η κλίμακα του σχεδίου αλλάζει. Για σχέδια σε πλήρη κλίμακα χρησιμοποιήστε το Πραγματικό μέγεθος. Reload documents when their files change on disk Επαναφόρτωση εγγράφων όταν αλλάζουν τα αρχεία τους στον δίσκο + Don't show again Να μην εμφανιστεί ξανά + Show a notice when a document is secured Εμφάνιση ειδοποίησης όταν ένα έγγραφο προστατεύεται Always use my default zoom and page layout Να χρησιμοποιείται πάντα η προεπιλεγμένη μεγέθυνση και διάταξη σελίδας μου This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Αυτό το αρχείο άλλαξε στον δίσκο. Η επαναφόρτωση δείχνει τη νέα έκδοση και απορρίπτει τις μη αποθηκευμένες αλλαγές σας· η αποθήκευση την αντικαθιστά με αυτή την έκδοση. Reload Επαναφόρτωση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 73b0ffbf1..4cd0fffb7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2193,6 +2193,8 @@ action wizard Start Iniciar Home / End Inicio / Fin H / Space (hold) H / Espacio (mantener) Reload documents when their files change on disk Recargar los documentos cuando sus archivos cambien en el disco + Don't show again No volver a mostrar + Show a notice when a document is secured Mostrar un aviso cuando un documento está protegido Always use my default zoom and page layout Usar siempre mi zoom y presentación de página predeterminados This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Este archivo cambió en el disco. Recargar muestra la versión nueva y descarta los cambios sin guardar; Guardar la reemplaza por esta versión. Reload Recargar diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index efcf3e3c6..1484273e3 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2268,6 +2268,8 @@ key Space Välilyönti Printer paper and scaling may differ. Check driver settings. Tulostimen paperi ja skaalaus voivat poiketa. Tarkista ohjaimen asetukset. Drawing scale changes. Use Actual size for full-scale plans. Piirustuksen mittakaava muuttuu. Käytä mittakaavaisille piirustuksille Todellinen koko -valintaa. Reload documents when their files change on disk Lataa asiakirjat uudelleen, kun niiden tiedostot muuttuvat levyllä + Don't show again Älä näytä uudelleen + Show a notice when a document is secured Näytä ilmoitus, kun asiakirja on suojattu This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Tämä tiedosto muuttui levyllä. Lataa se uudelleen nähdäksesi uuden version; tallentamattomat muutokset menetetään. Reload Lataa uudelleen Couldn't reload {name}: {e} Kohdetta {name} ei voitu ladata uudelleen: {e} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ec6e59e56..7abb710d7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2042,6 +2042,8 @@ Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Utilisé lorsqu'un PDF ne demande ni présentation ni zoom. Le défilement continu ne saute jamais d'une page à l'autre. Reopen the files that were open when PdfCraft last closed Rouvrir les fichiers qui étaient ouverts lors de la dernière fermeture de PdfCraft Reload documents when their files change on disk Recharger les documents lorsque leurs fichiers sont modifiés sur le disque + Don't show again Ne plus afficher + Show a notice when a document is secured Afficher un avis lorsqu’un document est protégé Always use my default zoom and page layout Toujours utiliser mon zoom et ma présentation de page par défaut This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ce fichier a été modifié sur le disque. Recharger affiche la nouvelle version et abandonne vos modifications non enregistrées ; Enregistrer la remplace par cette version. Reload Recharger diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7be5ec688..7ecafb060 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2250,6 +2250,8 @@ key Space Space Printer paper and scaling may differ. Check driver settings. प्रिंटर का काग़ज़ और स्केलिंग अलग हो सकते हैं। ड्राइवर सेटिंग्स जाँचें। Drawing scale changes. Use Actual size for full-scale plans. रेखाचित्र पैमाना बदल जाता है। पूर्ण-पैमाने योजनाओं के लिए “वास्तविक आकार” चुनें। Reload documents when their files change on disk जब उनकी फ़ाइलें डिस्क पर बदलें तो दस्तावेज़ दोबारा लोड करें + Don't show again फिर से न दिखाएँ + Show a notice when a document is secured दस्तावेज़ सुरक्षित होने पर सूचना दिखाएँ This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. यह फ़ाइल डिस्क पर बदल गई है। नया संस्करण देखने के लिए उसे दोबारा लोड करें; आपके असहेजे बदलाव खो जाएँगे। Reload दोबारा लोड करें Couldn't reload {name}: {e} {name} दोबारा लोड नहीं हो सका: {e} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f1723629c..e785247d6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2318,6 +2318,8 @@ key Space Szóköz Printer paper and scaling may differ. Check driver settings. A nyomtató papírja és méretezése eltérhet. Ellenőrizze az illesztőprogram beállításait. Drawing scale changes. Use Actual size for full-scale plans. A rajz méretaránya változik. Méretarányos tervekhez használja a Tényleges méret beállítást. Reload documents when their files change on disk Dokumentumok újratöltése, ha a fájljuk megváltozik a lemezen + Don't show again Ne jelenjen meg újra + Show a notice when a document is secured Értesítés megjelenítése, ha egy dokumentum védett Always use my default zoom and page layout Mindig az alapértelmezett nagyításom és oldalelrendezésem használata This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ez a fájl megváltozott a lemezen. Az Újratöltés az új változatot mutatja, és eldobja a nem mentett módosításokat; a Mentés erre a változatra cseréli le. Reload Újratöltés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3585b344e..0a7666895 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -924,6 +924,8 @@ Redaction code: Kode sensor: Reload Muat ulang Reload documents when their files change on disk Muat ulang dokumen saat file-nya berubah di disk + Don't show again Jangan tampilkan lagi + Show a notice when a document is secured Tampilkan pemberitahuan saat dokumen diamankan Remove checkmark Hapus centang Remove invalid links and bookmarks Hapus tautan dan bookmark yang tidak valid Remove page Hapus halaman diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0dc6bb087..ebd3b06cd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2328,6 +2328,8 @@ key Space Spazio Printer paper and scaling may differ. Check driver settings. Carta e scala della stampante possono differire. Controlla le impostazioni del driver. Drawing scale changes. Use Actual size for full-scale plans. La scala del disegno cambia. Usa Dimensioni effettive per le planimetrie in scala. Reload documents when their files change on disk Ricarica i documenti quando i loro file cambiano sul disco + Don't show again Non mostrare più + Show a notice when a document is secured Mostra un avviso quando un documento è protetto Always use my default zoom and page layout Usa sempre il mio ingrandimento e layout di pagina predefiniti This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Questo file è cambiato sul disco. Ricarica mostra la nuova versione e scarta le modifiche non salvate; Salva la sostituisce con questa versione. Reload Ricarica diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 963e8d364..f42e63d4c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2319,6 +2319,8 @@ key Space スペース Printer paper and scaling may differ. Check driver settings. プリンターの用紙や倍率は異なる場合があります。ドライバーの設定を確認してください。 Drawing scale changes. Use Actual size for full-scale plans. 図面の縮尺が変わります。原寸で印刷するには「実際のサイズ」を使用してください。 Reload documents when their files change on disk ディスク上のファイルが変更されたら文書を再読み込みする + Don't show again 今後表示しない + Show a notice when a document is secured 文書が保護されているときに通知を表示する Always use my default zoom and page layout 常に既定のズームとページレイアウトを使用する This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. このファイルはディスク上で変更されました。「再読み込み」は新しい版を表示して未保存の変更を破棄し、「保存」はこの版で置き換えます。 Reload 再読み込み diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 311626f34..632079b9b 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2318,6 +2318,8 @@ key Space Бос орын Printer paper and scaling may differ. Check driver settings. Принтер қағазы мен масштабы өзгеше болуы мүмкін. Драйвер параметрлерін тексеріңіз. Drawing scale changes. Use Actual size for full-scale plans. Сызба масштабы өзгереді. Толық масштабты жоспарлар үшін Нақты өлшемді пайдаланыңыз. Reload documents when their files change on disk Дискідегі файлдары өзгергенде құжаттарды қайта жүктеу + Don't show again Қайта көрсетпеу + Show a notice when a document is secured Құжат қорғалған кезде хабарландыру көрсету Always use my default zoom and page layout Әрқашан әдепкі масштаб пен бет орналасуын пайдалану This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Бұл файл дискіде өзгертілді. «Қайта жүктеу» жаңа нұсқаны көрсетіп, сақталмаған өзгерістеріңізді тастайды; «Сақтау» оны осы нұсқамен алмастырады. Reload Қайта жүктеу diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 48ef31e19..35d4f3a5b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2250,6 +2250,8 @@ key Space 스페이스 Printer paper and scaling may differ. Check driver settings. 프린터 용지와 배율이 다를 수 있습니다. 드라이버 설정을 확인하세요. Drawing scale changes. Use Actual size for full-scale plans. 도면 축척이 변경됩니다. 실제 크기 도면에는 '실제 크기'를 사용하세요. Reload documents when their files change on disk 파일이 디스크에서 변경되면 문서 다시 불러오기 + Don't show again 다시 표시 안 함 + Show a notice when a document is secured 문서가 보호되어 있으면 알림 표시 This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. 이 파일이 디스크에서 변경되었습니다. 새 버전을 보려면 다시 불러오세요. 저장하지 않은 변경 내용은 손실됩니다. Reload 다시 불러오기 Couldn't reload {name}: {e} {name}을(를) 다시 불러올 수 없습니다: {e} diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index a8a9f6f64..192b04eda 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2279,6 +2279,8 @@ key Space Space Printer paper and scaling may differ. Check driver settings. ເຈ້ຍເຄື່ອງພິມ ແລະ ມາດຕາສ່ວນອາດແຕກຕ່າງກັນ. ກວດສອບການຕັ້ງຄ່າໄດຣເວີ. Drawing scale changes. Use Actual size for full-scale plans. ມາດຕາສ່ວນຮູບແຕ້ມປ່ຽນແປງ. ໃຊ້ "ຂະໜາດຈິງ" ສຳລັບແບບແຜນເຕັມສ່ວນ. Reload documents when their files change on disk ໂຫຼດເອກະສານຄືນໃໝ່ເມື່ອໄຟລ໌ໃນດິສກ໌ມີການປ່ຽນແປງ + Don't show again ບໍ່ຕ້ອງສະແດງອີກ + Show a notice when a document is secured ສະແດງການແຈ້ງເຕືອນເມື່ອເອກະສານຖືກປ້ອງກັນໄວ້ This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. ໄຟລ໌ນີ້ມີການປ່ຽນແປງໃນດິສກ໌. "ໂຫຼດຄືນໃໝ່" ຈະສະແດງເວີຊັນໃໝ່ ແລະ ປະຖິ້ມການປ່ຽນແປງທີ່ຍັງບໍ່ໄດ້ບັນທຶກ; "ບັນທຶກ" ຈະຂຽນທັບດ້ວຍເວີຊັນນີ້. Reload ໂຫຼດຄືນໃໝ່ Couldn't reload {name}: {e} ບໍ່ສາມາດໂຫຼດ {name} ຄືນໃໝ່ໄດ້: {e} diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index e2476f196..7085f0842 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -975,6 +975,8 @@ Redaction code: Šalinimo kodas: Reload Įkelti iš naujo Reload documents when their files change on disk Iš naujo įkelti dokumentus, kai pasikeičia jų failai diske + Don't show again Daugiau nerodyti + Show a notice when a document is secured Rodyti pranešimą, kai dokumentas apsaugotas Always use my default zoom and page layout Visada naudoti mano numatytąjį mastelį ir puslapio išdėstymą Remove checkmark Pašalinti varnelę Remove invalid links and bookmarks Pašalinti netinkamus saitus ir žymeles diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 27c1383ec..b65774323 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -933,6 +933,8 @@ Redaction code: Sladdekode: Reload Last inn på nytt Reload documents when their files change on disk Last inn dokumenter på nytt når filene endres på disk + Don't show again Ikke vis igjen + Show a notice when a document is secured Vis et varsel når et dokument er sikret Remove checkmark Fjern hake Remove invalid links and bookmarks Fjern ugyldige lenker og bokmerker Remove page Fjern side diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6836f3e0a..03695e512 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2299,6 +2299,8 @@ key Space Spacja These objects could not be moved. Select them again and try a smaller move. Nie udało się przenieść tych obiektów. Zaznacz je ponownie i spróbuj mniejszego przesunięcia. This page contains objects that cannot be selected together. Ta strona zawiera obiekty, których nie można zaznaczyć razem. Reload documents when their files change on disk Wczytuj ponownie dokumenty, gdy ich pliki zmienią się na dysku + Don't show again Nie pokazuj ponownie + Show a notice when a document is secured Pokazuj powiadomienie, gdy dokument jest zabezpieczony Always use my default zoom and page layout Zawsze używaj mojego domyślnego powiększenia i układu stron This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ten plik zmienił się na dysku. Wczytaj ponownie pokazuje nową wersję i odrzuca niezapisane zmiany; Zapisz zastępuje ją tą wersją. Reload Wczytaj ponownie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dcbebae95..133fd903c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2321,6 +2321,8 @@ key Space Espaço Printer paper and scaling may differ. Check driver settings. O papel e a escala da impressora podem variar. Confira as configurações do driver. Drawing scale changes. Use Actual size for full-scale plans. A escala do desenho muda. Use Tamanho real para plantas em escala. Reload documents when their files change on disk Recarregar documentos quando os arquivos mudarem no disco + Don't show again Não mostrar novamente + Show a notice when a document is secured Mostrar um aviso quando um documento estiver protegido Always use my default zoom and page layout Sempre usar meu zoom e layout da página padrão This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Este arquivo foi alterado no disco. Recarregar mostra a nova versão e descarta suas alterações não salvas; Salvar a substitui por esta versão. Reload Recarregar diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 0003e3851..0df2223de 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -933,6 +933,8 @@ Redaction code: Cod de anonimizare: Reload Reîncărcare Reload documents when their files change on disk Reîncarcă documentele când fișierele lor se modifică pe disc + Don't show again Nu mai afișa + Show a notice when a document is secured Afișează o notificare când un document este securizat Remove checkmark Eliminare bifă Remove invalid links and bookmarks Eliminare linkuri și semne de carte nevalide Remove page Eliminare pagină diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0dc40fdba..6db3f5913 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2109,6 +2109,8 @@ Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Используется, когда PDF не запрашивает макет или масштаб. Непрерывная прокрутка никогда не привязывается между страницами. Reopen the files that were open when PdfCraft last closed Повторно открывать файлы, которые были открыты при последнем закрытии PdfCraft Reload documents when their files change on disk Перезагружать документы при изменении их файлов на диске + Don't show again Больше не показывать + Show a notice when a document is secured Показывать уведомление, когда документ защищён Always use my default zoom and page layout Всегда использовать мой масштаб и макет страницы по умолчанию This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Этот файл был изменён на диске. «Перезагрузить» покажет новую версию и отбросит несохранённые изменения; «Сохранить» заменит её этой версией. Reload Перезагрузить diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index f5e374ae7..703b9bda0 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2325,6 +2325,8 @@ key Space Preslednica Printer paper and scaling may differ. Check driver settings. Papir in merilo tiskalnika se lahko razlikujeta. Preverite nastavitve gonilnika. Drawing scale changes. Use Actual size for full-scale plans. Merilo risbe se spremeni. Za načrte v polnem merilu uporabite Dejansko velikost. Reload documents when their files change on disk Znova naloži dokumente, ko se njihove datoteke na disku spremenijo + Don't show again Ne prikaži več + Show a notice when a document is secured Prikaži obvestilo, ko je dokument zaščiten Always use my default zoom and page layout Vedno uporabi moje privzeto povečanje in postavitev strani This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ta datoteka se je na disku spremenila. Ponovno naloži pokaže novo različico in zavrže neshranjene spremembe; Shrani jo zamenja s to različico. Reload Znova naloži diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index c93efee5a..8921a92a2 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2263,6 +2263,8 @@ key Space Hapësirë Printer paper and scaling may differ. Check driver settings. Letra dhe shkallëzimi i printerit mund të ndryshojnë. Kontrolloni cilësimet e drajverit. Drawing scale changes. Use Actual size for full-scale plans. Shkalla e vizatimit ndryshon. Përdorni Madhësinë aktuale për planet në shkallë të plotë. Reload documents when their files change on disk Ringarko dokumentet kur skedarët e tyre ndryshojnë në disk + Don't show again Mos e shfaq më + Show a notice when a document is secured Shfaq një njoftim kur një dokument është i siguruar This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Ky skedar ndryshoi në disk. Ringarkojeni për të parë versionin e ri; ndryshimet tuaja të paruajtura do të humbasin. Reload Ringarko Couldn't reload {name}: {e} Nuk u ringarkua {name}: {e} diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index fa3f6cec4..50366f74c 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2028,6 +2028,8 @@ action wizard Start ప్రారంభించు Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF ఏ లేఅవుట్‌ను గానీ జూమ్‌ను గానీ కోరనప్పుడు ఉపయోగించబడుతుంది. నిరంతర స్క్రోలింగ్ పేజీల మధ్య ఎప్పుడూ స్నాప్ అవ్వదు. Reopen the files that were open when PdfCraft last closed PdfCraftను చివరిసారి మూసివేసినప్పుడు తెరిచి ఉన్న ఫైల్‌లను మళ్లీ తెరువు Reload documents when their files change on disk డిస్క్‌లో వాటి ఫైల్‌లు మారినప్పుడు పత్రాలను మళ్లీ లోడ్ చేయి + Don't show again మళ్లీ చూపవద్దు + Show a notice when a document is secured పత్రం రక్షించబడినప్పుడు నోటీసు చూపు Always use my default zoom and page layout ఎల్లప్పుడూ నా డిఫాల్ట్ జూమ్ మరియు పేజీ లేఅవుట్‌ను ఉపయోగించు This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. ఈ ఫైల్ డిస్క్‌లో మారింది. రీలోడ్ కొత్త వెర్షన్‌ను చూపించి సేవ్ చేయని మార్పులను వదిలేస్తుంది; సేవ్ దాని స్థానంలో ఈ వెర్షన్‌ను ఉంచుతుంది. Reload మళ్లీ లోడ్ చేయి diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index e56770757..c9f327b69 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2226,6 +2226,8 @@ signature pad Type พิมพ์ Printer paper and scaling may differ. Check driver settings. กระดาษและการปรับขนาดของเครื่องพิมพ์อาจต่างออกไป โปรดตรวจสอบการตั้งค่าไดรเวอร์ Drawing scale changes. Use Actual size for full-scale plans. มาตราส่วนของแบบจะเปลี่ยน ใช้ขนาดจริงสำหรับแบบแปลนเต็มมาตราส่วน Reload documents when their files change on disk โหลดเอกสารใหม่เมื่อไฟล์บนดิสก์เปลี่ยนแปลง + Don't show again ไม่ต้องแสดงอีก + Show a notice when a document is secured แสดงการแจ้งเตือนเมื่อเอกสารมีการรักษาความปลอดภัย This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. ไฟล์นี้เปลี่ยนแปลงบนดิสก์ โหลดใหม่เพื่อดูรุ่นใหม่ การเปลี่ยนแปลงที่ยังไม่ได้บันทึกจะหายไป Reload โหลดใหม่ Couldn't reload {name}: {e} โหลด {name} ใหม่ไม่ได้: {e} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c292b8863..815873a25 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2316,6 +2316,8 @@ key Space Boşluk Printer paper and scaling may differ. Check driver settings. Yazıcı kâğıdı ve ölçekleme farklı olabilir. Sürücü ayarlarını kontrol edin. Drawing scale changes. Use Actual size for full-scale plans. Çizim ölçeği değişir. Tam ölçekli planlar için Gerçek boyutu kullanın. Reload documents when their files change on disk Diskteki dosyaları değiştiğinde belgeleri yeniden yükle + Don't show again Bir daha gösterme + Show a notice when a document is secured Bir belge güvenli olduğunda bildirim göster Always use my default zoom and page layout Her zaman varsayılan yakınlaştırmamı ve sayfa düzenimi kullan This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Bu dosya diskte değişti. Yeniden yükle yeni sürümü gösterir ve kaydedilmemiş değişikliklerinizi atar; Kaydet onu bu sürümle değiştirir. Reload Yeniden yükle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cc8f45765..90c5ceb4a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2341,6 +2341,8 @@ key Space Пробіл Printer paper and scaling may differ. Check driver settings. Папір і масштаб принтера можуть відрізнятися. Перевірте налаштування драйвера. Drawing scale changes. Use Actual size for full-scale plans. Масштаб креслення змінюється. Для друку в початковому масштабі виберіть Фактичний розмір. Reload documents when their files change on disk Перезавантажувати документи, коли їхні файли змінюються на диску + Don't show again Більше не показувати + Show a notice when a document is secured Показувати сповіщення, коли документ захищено Always use my default zoom and page layout Завжди використовувати мій типовий масштаб і макет сторінок This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Цей файл змінено на диску. «Перезавантажити» покаже нову версію й відкине незбережені зміни; «Зберегти» замінить її цією версією. Reload Перезавантажити diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 87abe5129..c0786df07 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -969,6 +969,8 @@ theme Appearance Giao diện Redaction code: Mã xóa nội dung nhạy cảm: Reload Tải lại Reload documents when their files change on disk Tải lại tài liệu khi tệp của chúng thay đổi trên đĩa + Don't show again Không hiển thị lại + Show a notice when a document is secured Hiển thị thông báo khi tài liệu được bảo mật Always use my default zoom and page layout Luôn dùng thu phóng và bố cục trang mặc định của tôi Remove checkmark Bỏ dấu kiểm Remove invalid links and bookmarks Gỡ các liên kết và dấu trang không hợp lệ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 594f68df8..b13af1286 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2143,6 +2143,8 @@ Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF 未指定布局或缩放时使用。连续滚动时页面不会逐页吸附。 Reopen the files that were open when PdfCraft last closed 重新打开上次关闭 PdfCraft 时打开的文件 Reload documents when their files change on disk 文件在磁盘上更改时重新加载对应的文档 + Don't show again 不再显示 + Show a notice when a document is secured 文档已加密时显示通知 Always use my default zoom and page layout 始终使用我的默认缩放和页面布局 This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. 此文件已在磁盘上更改。“重新加载”显示新版本并放弃未保存的更改;“保存”则用此版本替换它。 Reload 重新加载 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb578ba26..667f84217 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2193,6 +2193,8 @@ key Space 空格 Tools 工具 Inspector 檢查器 Reload documents when their files change on disk 檔案在磁碟上變更時重新載入對應的文件 + Don't show again 不再顯示 + Show a notice when a document is secured 文件已加密時顯示通知 Always use my default zoom and page layout 一律使用我的預設縮放與頁面佈局 This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. 此檔案已在磁碟上變更。「重新載入」會顯示新版本並捨棄未儲存的變更;「儲存」則會以此版本取代它。 Reload 重新載入 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 7bc3dd044..dadd9c29d 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -394,6 +394,8 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok // The web build has no files on disk to watch. #[cfg(not(target_arch = "wasm32"))] ui.add(widgets::checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk"))); + // The notice's "Don't show again" turns this off (#4035); the restrictions stay either way. + ui.add(widgets::checkbox(&mut app.show_secured_notice, tl!("Show a notice when a document is secured"))); ui.horizontal(|ui| { ui.label(tl!("Scroll speed (mouse wheel and arrow keys)")); for speed in crate::scroll_speed::ScrollSpeed::ALL { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64b22ff18..f2dbabe19 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -589,6 +589,9 @@ pub struct PdfCraftApp { quit_session: Option, /// Preferences: reload a document when its file changes on disk (#431). pub reload_changed_files: bool, + /// Preferences: tell the user, above the pages, that a document is secured (its security + /// settings restrict changes). Off only hides that notice (#4035); kept in the settings. + pub show_secured_notice: bool, /// Preferences: open every document at Default zoom and Default page display, whatever /// layout or zoom it asks for (#1618). pub always_default_view: bool, @@ -986,6 +989,7 @@ impl PdfCraftApp { last_session: Default::default(), quit_session: None, reload_changed_files: true, + show_secured_notice: true, always_default_view: false, #[cfg(not(target_arch = "wasm32"))] watch: Default::default(), @@ -2426,6 +2430,7 @@ impl PdfCraftApp { v["redact_prefs"] = self.redact_defaults.to_json(); // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); + v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); v["organize_zoom"] = serde_json::json!(self.organize_zoom); // Canonical names survive changes to the order of PAPERS and do not depend on UI language. v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)); @@ -2471,6 +2476,9 @@ impl PdfCraftApp { if let Some(on) = v["reload_changed_files"].as_bool() { self.reload_changed_files = on; } + if let Some(on) = v["show_secured_notice"].as_bool() { + self.show_secured_notice = on; + } self.pinned.restore(&v["pinned_folders"]); if let Ok(preference) = serde_json::from_value::(v["theme"].clone()) { self.set_theme_preference(preference); @@ -2899,6 +2907,14 @@ impl PdfCraftApp { }; } ("ui-scale", _) => self.ui_scale = ui_scale::parse(value)?, + // `--secured-notice false`: no notice above the pages that a document is secured. + ("secured-notice", _) => { + self.show_secured_notice = match value { + "true" => true, + "false" => false, + _ => return Err("secured-notice must be true or false".into()), + }; + } ("system-title-bar", _) => { self.system_title_bar = match value { "true" => true, diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 71ba9ccda..0671a8f83 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -2348,6 +2348,61 @@ fn the_security_notice_unlocks_with_the_permissions_password() { assert!(!h.state().session.get(h.state().active_ids().unwrap().1).unwrap().dirty, "unlocking is not an edit"); } +/// #4035: the secured-document notice can be turned off for good. "Don't show again" hides it +/// in every document, the choice is kept in the settings and turned back on in Preferences (or +/// `--secured-notice`); the document stays restricted and Properties ▸ Security still opens. +#[test] +fn the_security_notice_can_be_turned_off_for_good() { + let open = |settings: Option| { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + if let Some(s) = &settings { + app.restore(s); + } + app.set_option("language", "en").unwrap(); + app.open_bytes("locked.pdf", None, protected("", "owner", 0b0100)).unwrap(); + app + }); + h.run_steps(4); + h + }; + let mut h = open(None); + assert!(h.state().show_secured_notice, "on by default"); + h.get_by_label_contains("This document is secured"); + h.get_by_label("Don't show again").click(); + h.run_steps(2); + assert!(h.query_by_label_contains("This document is secured").is_none()); + assert!(!h.state().show_secured_notice); + let id = h.state().active_ids().unwrap().1; + assert!(h.state().session.get(id).unwrap().security_summary().is_some_and(|s| !s.owner), "still restricted"); + h.state_mut().set_option("dialog", "properties").unwrap(); + h.run_steps(2); + h.get_by_label("Security").click(); + h.run_steps(3); + h.get_by_label("Password security"); + h.get_by_label("User password"); + // Kept in the settings: a later session opens the same file without the notice. + let saved = h.state().persist(); + let mut h = open(Some(saved)); + assert!(h.query_by_label_contains("This document is secured").is_none(), "remembered across a restart"); + // Back on from Preferences. + h.state_mut().set_option("dialog", "preferences").unwrap(); + h.run_steps(2); + h.get_by_role_and_label(Role::CheckBox, "Show a notice when a document is secured").scroll_to_me(); + h.run_steps(4); + h.get_by_role_and_label(Role::CheckBox, "Show a notice when a document is secured").click(); + h.run_steps(2); + assert!(h.state().show_secured_notice); + h.state_mut().dialog = None; + h.run_steps(2); + h.get_by_label_contains("This document is secured"); + // Scriptable, with a clear refusal for anything else. + let app = h.state_mut(); + app.set_option("secured-notice", "false").unwrap(); + assert!(!app.show_secured_notice); + assert_eq!(app.set_option("secured-notice", "maybe"), Err("secured-notice must be true or false".into())); +} + /// Changing a restricted document's security asks for its permissions password first (Acrobat /// does too), then carries on. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 45dda3982..83b038c3a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1802,8 +1802,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/it/xfa.rs::a_secured_dynamic_xfa_form_shows_its_layout_and_its_security"] -notes = "On a secured form (fields or XFA), the security sentence follows the form's notice, which keeps Highlight fields and the XFA layout notes." +evidence = ["crates/ui-egui/tests/it/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/it/xfa.rs::a_secured_dynamic_xfa_form_shows_its_layout_and_its_security", "crates/ui-egui/tests/it/editing.rs::the_security_notice_can_be_turned_off_for_good"] +notes = "On a secured form (fields or XFA), the security sentence follows the form's notice, which keeps Highlight fields and the XFA layout notes. The notice's Don't show again turns it off in every document (#4035), kept in the settings and turned back on in Preferences (--secured-notice); the restrictions and Document Properties > Security are unchanged." [[feature]] id = "view.pdfa-view-mode" From cf730534f0ec2967edb0222b5d8dfdce9516603a Mon Sep 17 00:00:00 2001 From: Lombardi2003 Date: Thu, 22 Oct 2026 18:13:59 +0200 Subject: [PATCH 18/59] fix(ui): "Don't show again" only on the bar that shows the security notice (#4035) From review of #4096: when a secured form's scripts were turned off, the bar shows that message instead of the security one, but still offered "Don't show again", which would turn off a notice the user had not seen. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 45265fe9d..d06efd0ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4920,7 +4920,8 @@ fn notices( if secured && doc.can_unlock() && crate::widgets::pill_button(ui, tl!("Unlock…"), true).clicked() { unlock = true; } - if secured && crate::widgets::pill_button(ui, tl!("Don't show again"), false).clicked() { + // Only when the bar shows the security message: a form's stopped scripts come first. + if secured && !scripts_off && crate::widgets::pill_button(ui, tl!("Don't show again"), false).clicked() { hide_secured = true; } if repaired && !secured && info.fields.is_empty() && crate::widgets::pill_button(ui, tl!("Details"), false).clicked() { From 5f042c691f3456cd7c601db782a3b5a54b861cfd Mon Sep 17 00:00:00 2001 From: Juan Jurado Date: Thu, 22 Oct 2026 16:45:39 -0600 Subject: [PATCH 19/59] M10: every paper size reaches the printer, from the dialog and doc_print (#3752) A CUPS job that names no paper gets the queue's PPD default (A4 on the reporter's Xerox queue), whatever the sheets were laid out on. Two gaps remained after #1583: - cups_media had no name for 13 of the 21 PAPERS sizes (A6, ARCH A-E1, ANSI C-E, A0-A2), so the Print dialog sent no media for them. They now use PWG 5101.1 names (the IANA-registered na_arch-e1_30x42in for ARCH E1), which carry their dimensions: CUPS feeds the driver's size of those dimensions whatever the driver calls it, and driverless IPP printers use them natively. Checked with cupsfilter against a synthetic PPD whose sizes use vendor keywords (ARCHD, AnsiD, A0...): each name produced its size's bounding box. The 8 sizes that already worked keep their PPD keywords. - doc_print to a printer (CLI run, MCP) sent no paper at all. It now resolves the paper to its PAPERS index and sends the same media option as the dialog, after the driver options. The job is built in spool_job so it can be tested without printing. print::media_option is the one place both get the option from. Both new tests were seen failing without the fix. The macOS tray half of #3752 (Auto Select instead of the PPD's default tray) is a craft-print change. --- ROADMAP.md | 1 + crates/automation/src/printing.rs | 111 ++++++++++++++++++++---------- crates/print/src/lib.rs | 50 ++++++++++---- crates/print/src/tests.rs | 28 ++++++++ crates/ui-egui/src/print_ui.rs | 2 +- parity/acrobat-features.toml | 4 +- 6 files changed, 145 insertions(+), 51 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 27c8e1b6b..ac34f9ae0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -190,6 +190,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 39cd45760..e43c3c6e9 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -26,6 +26,54 @@ fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) } +/// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS` +/// index and size; US Letter when none is given. +fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { + let mut papers = PAPERS.iter().enumerate(); + let found = match name { + None => papers.next(), + Some(p) => papers.find(|(_, (n, _))| { + n.eq_ignore_ascii_case(p) + || n.replace("US ", "").eq_ignore_ascii_case(p) + || n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p)) + }), + }; + found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| { + bad(format!( + "unknown paper {:?}; choose one of {}", + name.unwrap_or_default(), + PAPERS.iter().map(|(name, _)| *name).collect::>().join(", ") + )) + }) +} + +/// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on +/// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default +/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752). +fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize) -> Result { + let options = match a.get("options") { + None => Vec::new(), + Some(Value::Object(m)) => m + .iter() + .map(|(k, v)| match v.as_str() { + Some(v) => Ok((k.clone(), v.to_string())), + None => Err(bad(format!("options.{k} must be a string (a choice from printer_options)"))), + }) + .collect::>>()?, + Some(_) => return Err(bad("options must be an object of option keys and choices from printer_options")), + }; + Ok(spool::Job { + printer: if printer == "default" { None } else { Some(printer.to_string()) }, + copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32, + collate: a.opt_bool("collate")?.unwrap_or(true), + duplex, + scaling: spool::Scaling::Exact, + grayscale: a.opt_bool("grayscale")?.unwrap_or(false), + title, + options: options.into_iter().chain(print::media_option(paper)).collect(), + }) +} + impl Automation { pub(crate) fn printers(&self) -> Result { let list: Vec = spool::printers() @@ -125,20 +173,7 @@ impl Automation { "form-fields-only" => Content::FormFieldsOnly, c => return Err(bad(format!("unknown comments_forms {c:?}"))), }; - let paper = match a.opt_str("paper")? { - None => PAPERS[0].1, - Some(p) => PAPERS - .iter() - .find(|(n, _)| { - n.eq_ignore_ascii_case(p) - || n.replace("US ", "").eq_ignore_ascii_case(p) - || n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p)) - }) - .map(|x| x.1) - .ok_or_else(|| { - bad(format!("unknown paper {p:?}; choose one of {}", PAPERS.iter().map(|(name, _)| *name).collect::>().join(", "))) - })?, - }; + let (paper_index, paper) = paper_named(a.opt_str("paper")?)?; // Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when // `grayscale` is also asked for. let raster = if a.opt_bool("as_image")?.unwrap_or(false) { @@ -162,27 +197,7 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let name = self.doc(a)?.name.clone(); - let job = spool::Job { - printer: if printer == "default" { None } else { Some(printer.to_string()) }, - copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32, - collate: a.opt_bool("collate")?.unwrap_or(true), - duplex, - scaling: spool::Scaling::Exact, - grayscale: a.opt_bool("grayscale")?.unwrap_or(false), - title: name, - options: match a.get("options") { - None => Vec::new(), - Some(Value::Object(m)) => m - .iter() - .map(|(k, v)| match v.as_str() { - Some(v) => Ok((k.clone(), v.to_string())), - None => Err(bad(format!("options.{k} must be a string (a choice from printer_options)"))), - }) - .collect::>>()?, - Some(_) => return Err(bad("options must be an object of option keys and choices from printer_options")), - }, - }; + let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), @@ -207,4 +222,30 @@ mod tests { assert!(duplex("long-edge", true).is_err()); assert!(duplex("sideways", false).is_err()); } + + /// #3752: `doc_print` to a printer sent no paper, so the queue's default paper stood in for + /// the one the sheets were laid out on (A4 under Letter sheets on the report's queue). + #[test] + fn printing_to_a_printer_feeds_the_laid_out_paper() { + let job = |args: Value, paper_name: Option<&str>| { + let (index, _) = paper_named(paper_name).unwrap(); + spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap() + }; + let media = |m: &str| ("media".to_string(), m.to_string()); + // US Letter when no paper is asked for, after the driver's own options. + let letter = job(json!({ "options": { "InputSlot": "Tray3" } }), None); + assert_eq!(letter.options, [("InputSlot".to_string(), "Tray3".to_string()), media("Letter")]); + assert_eq!((letter.printer.as_deref(), letter.title.as_str()), (Some("Office"), "a.pdf")); + // Every paper, by its full name, goes out as its own media name. + for (i, (name, size)) in PAPERS.iter().enumerate() { + assert_eq!(paper_named(Some(name)).ok(), Some((i, *size)), "{name}"); + assert_eq!(job(json!({}), Some(name)).options, print::media_option(i).into_iter().collect::>(), "{name}"); + } + // Short forms find the same paper. + assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); + assert_eq!(job(json!({}), Some("ARCH D")).options, [media("arch_d_24x36in")]); + assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); + assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); + assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); + } } diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 6839e5f6b..80b7be27a 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -307,20 +307,44 @@ pub const PAPERS: [(&str, (f64, f64)); 21] = [ pub const LABEL_PAPER: usize = 19; /// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out -/// on rather than the queue's default (PPD keywords; `w288h432` is the usual 4×6 label, `Env10` -/// the US #10 envelope). +/// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes +/// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). +/// The rest use PWG 5101.1 names (the IANA registry for ARCH E1), which carry their dimensions: +/// CUPS feeds the PPD size of those dimensions whatever the driver calls it (`ARCHD`, `AnsiD`), +/// and driverless IPP printers use these names themselves (#3752). pub fn cups_media(index: usize) -> Option<&'static str> { - match PAPERS.get(index)?.0 { - "US Letter" => Some("Letter"), - "US Legal" => Some("Legal"), - "Tabloid" => Some("Tabloid"), - "A3" => Some("A3"), - "A4" => Some("A4"), - "A5" => Some("A5"), - "US #10 Envelope (4.125 x 9.5 in)" => Some("Env10"), - _ if index == LABEL_PAPER => Some("w288h432"), - _ => None, - } + Some(match PAPERS.get(index)?.0 { + "US Letter" => "Letter", + "US Legal" => "Legal", + "Tabloid" => "Tabloid", + "A3" => "A3", + "A4" => "A4", + "A5" => "A5", + "A6" => "iso_a6_105x148mm", + "ARCH A (9 x 12 in)" => "arch_a_9x12in", + "ARCH B (12 x 18 in)" => "arch_b_12x18in", + "ARCH C (18 x 24 in)" => "arch_c_18x24in", + "ARCH D (24 x 36 in)" => "arch_d_24x36in", + "ARCH E1 (30 x 42 in)" => "na_arch-e1_30x42in", + "ARCH E (36 x 48 in)" => "arch_e_36x48in", + "ANSI C (17 x 22 in)" => "na_c_17x22in", + "ANSI D (22 x 34 in)" => "na_d_22x34in", + "ANSI E (34 x 44 in)" => "na_e_34x44in", + "A2" => "iso_a2_420x594mm", + "A1" => "iso_a1_594x841mm", + "A0" => "iso_a0_841x1189mm", + "US #10 Envelope (4.125 x 9.5 in)" => "Env10", + _ if index == LABEL_PAPER => "w288h432", + // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's + // paper stands, as it did for every size before #1583. + _ => return None, + }) +} + +/// The job option that feeds `PAPERS[index]` (`media`, see [`cups_media`]), for the Print +/// dialog's jobs and `doc_print`'s alike. +pub fn media_option(index: usize) -> Option<(String, String)> { + cups_media(index).map(|m| ("media".to_string(), m.to_string())) } /// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 99634f1aa..a17adef15 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -674,6 +674,34 @@ fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() } } +#[test] +fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { + // #3752: 13 of the 21 sizes had no media name, so their jobs went out on the queue's default + // paper (A4 on the report's queue) whatever the sheets were laid out on. + let ppd_keywords = ["Letter", "Legal", "Tabloid", "A3", "A4", "A5", "w288h432", "Env10"]; + for (i, (name, (w, h))) in PAPERS.iter().enumerate() { + let media = cups_media(i).unwrap_or_else(|| panic!("{name} has no media name")); + assert_eq!(media_option(i), Some(("media".to_string(), media.to_string()))); + let args = lp_args(&Job { options: media_option(i).into_iter().collect(), ..Job::default() }); + assert!(args.contains(&format!("media={media}")), "{name}: lp is given it: {args:?}"); + if ppd_keywords.contains(&media) { + continue; + } + // The rest are PWG 5101.1 names, which end in their dimensions: CUPS feeds the driver's + // size of those dimensions, so they must be the paper's. + let dims = media.rsplit('_').next().unwrap_or_default(); + let (dims, points_per_unit) = match (dims.strip_suffix("mm"), dims.strip_suffix("in")) { + (Some(mm), _) => (mm, 72.0 / 25.4), + (_, Some(inches)) => (inches, 72.0), + _ => panic!("{name}: {media} is not a PWG name"), + }; + let (pw, ph) = dims.split_once('x').unwrap_or_else(|| panic!("{name}: {media} has no dimensions")); + let (pw, ph) = (pw.parse::().unwrap() * points_per_unit, ph.parse::().unwrap() * points_per_unit); + assert!((pw - w).abs() < 0.5 && (ph - h).abs() < 0.5, "{name}: {media} is {pw:.2} x {ph:.2} pt, not {w} x {h}"); + } + assert_eq!((cups_media(PAPERS.len()), media_option(PAPERS.len())), (None, None)); +} + #[test] fn us_number_10_envelope_is_offered_and_fed_by_name() { // 4.125 × 9.5 in (#2734). diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index cab6bfc23..d5d95c881 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -240,7 +240,7 @@ impl PrintDraft { } .into_iter() // The sheet the PDF was laid out on, not the queue's default paper. - .chain(print::cups_media(self.paper).map(|m| ("media".to_string(), m.to_string()))) + .chain(print::media_option(self.paper)) .collect(), } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 45dda3982..fc56e5f4e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7965,8 +7965,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print", "printers"] -evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page"] -notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences" +evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper"] +notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences. CUPS jobs from the dialog and doc_print ask for the chosen paper (media) in every size: PPD keywords for the common ones, PWG 5101.1 names for A6, A0–A2, ARCH and ANSI C–E, checked against a synthetic PPD with cupsfilter, not on a physical large-format printer (#3752). On macOS jobs still take the PPD's default paper tray until craft-print sends Auto Select (#3752)" [[feature]] id = "print.printer-selection" From 75947139379cf3c1a00fd180d71b6f999e210134 Mon Sep 17 00:00:00 2001 From: Juan Jurado Date: Thu, 22 Oct 2026 18:24:54 -0600 Subject: [PATCH 20/59] M10: spell the ARCH media names as PWG 5101.1 does (#3752) ARCH A-E went out as arch_a_9x12in .. arch_e_36x48in; PWG 5101.1 names them na_arch-a_9x12in .. na_arch-e_36x48in (ARCH E1's IANA-registered na_arch-e1_30x42in already had the prefix). CUPS parses either from the dimensions, which is why the cupsfilter check passed, but a driverless printer may accept only the names it advertises. Re-checked the corrected names with cupsfilter against the synthetic PPD. The paper test now pins every size's exact name, and fails on the old spelling. Doc comments for doc_print and PrintDraft::job. Review: storytold/pdfcraft#4161 (CodeRabbit). --- crates/automation/src/printing.rs | 4 +++- crates/print/src/lib.rs | 17 ++++++++-------- crates/print/src/tests.rs | 34 +++++++++++++++++++++++++++++-- crates/ui-egui/src/print_ui.rs | 2 ++ 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index e43c3c6e9..111d27eec 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -101,6 +101,8 @@ impl Automation { Ok(json!({ "printer": printer, "count": list.len(), "options": list })) } + /// `doc_print`: lay the document out with the Print dialog's options, then save the + /// print-ready PDF to `path` or send it to `printer` with the chosen paper. pub(crate) fn doc_print(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let (id, count) = (doc.id, doc.info.pages.len()); @@ -243,7 +245,7 @@ mod tests { } // Short forms find the same paper. assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); - assert_eq!(job(json!({}), Some("ARCH D")).options, [media("arch_d_24x36in")]); + assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 80b7be27a..75b6fdf1f 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -309,9 +309,10 @@ pub const LABEL_PAPER: usize = 19; /// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out /// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes /// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). -/// The rest use PWG 5101.1 names (the IANA registry for ARCH E1), which carry their dimensions: -/// CUPS feeds the PPD size of those dimensions whatever the driver calls it (`ARCHD`, `AnsiD`), -/// and driverless IPP printers use these names themselves (#3752). +/// The rest use PWG 5101.1 names (for ARCH E1, which 5101.1 lacks, the IANA registry's), spelled +/// exactly: a driverless printer may take only the names it advertises. They carry their +/// dimensions, so CUPS feeds the PPD size of those dimensions whatever the driver calls it +/// (`ARCHD`, `AnsiD`) (#3752). pub fn cups_media(index: usize) -> Option<&'static str> { Some(match PAPERS.get(index)?.0 { "US Letter" => "Letter", @@ -321,12 +322,12 @@ pub fn cups_media(index: usize) -> Option<&'static str> { "A4" => "A4", "A5" => "A5", "A6" => "iso_a6_105x148mm", - "ARCH A (9 x 12 in)" => "arch_a_9x12in", - "ARCH B (12 x 18 in)" => "arch_b_12x18in", - "ARCH C (18 x 24 in)" => "arch_c_18x24in", - "ARCH D (24 x 36 in)" => "arch_d_24x36in", + "ARCH A (9 x 12 in)" => "na_arch-a_9x12in", + "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", + "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", + "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", "ARCH E1 (30 x 42 in)" => "na_arch-e1_30x42in", - "ARCH E (36 x 48 in)" => "arch_e_36x48in", + "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", "ANSI C (17 x 22 in)" => "na_c_17x22in", "ANSI D (22 x 34 in)" => "na_d_22x34in", "ANSI E (34 x 44 in)" => "na_e_34x44in", diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index a17adef15..e7777fa1a 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -674,10 +674,10 @@ fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() } } +/// #3752: 13 of the 21 sizes had no media name, so their jobs went out on the queue's default +/// paper (A4 on the report's queue) whatever the sheets were laid out on. #[test] fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { - // #3752: 13 of the 21 sizes had no media name, so their jobs went out on the queue's default - // paper (A4 on the report's queue) whatever the sheets were laid out on. let ppd_keywords = ["Letter", "Legal", "Tabloid", "A3", "A4", "A5", "w288h432", "Env10"]; for (i, (name, (w, h))) in PAPERS.iter().enumerate() { let media = cups_media(i).unwrap_or_else(|| panic!("{name} has no media name")); @@ -700,6 +700,36 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { assert!((pw - w).abs() < 0.5 && (ph - h).abs() < 0.5, "{name}: {media} is {pw:.2} x {ph:.2} pt, not {w} x {h}"); } assert_eq!((cups_media(PAPERS.len()), media_option(PAPERS.len())), (None, None)); + // The names themselves, spelled as PWG 5101.1 has them: a driverless printer may take only the + // names it advertises, so one CUPS can merely parse (ARCH A was `arch_a_9x12in`) is not enough. + // ARCH E1 isn't in 5101.1; its name is the IANA registry's. + let names: Vec<(&str, &str)> = PAPERS.iter().enumerate().map(|(i, (name, _))| (*name, cups_media(i).unwrap_or_default())).collect(); + assert_eq!( + names, + [ + ("US Letter", "Letter"), + ("US Legal", "Legal"), + ("Tabloid", "Tabloid"), + ("A3", "A3"), + ("A4", "A4"), + ("A5", "A5"), + ("A6", "iso_a6_105x148mm"), + ("ARCH A (9 x 12 in)", "na_arch-a_9x12in"), + ("ARCH B (12 x 18 in)", "na_arch-b_12x18in"), + ("ARCH C (18 x 24 in)", "na_arch-c_18x24in"), + ("ARCH D (24 x 36 in)", "na_arch-d_24x36in"), + ("ARCH E1 (30 x 42 in)", "na_arch-e1_30x42in"), + ("ARCH E (36 x 48 in)", "na_arch-e_36x48in"), + ("ANSI C (17 x 22 in)", "na_c_17x22in"), + ("ANSI D (22 x 34 in)", "na_d_22x34in"), + ("ANSI E (34 x 44 in)", "na_e_34x44in"), + ("A2", "iso_a2_420x594mm"), + ("A1", "iso_a1_594x841mm"), + ("A0", "iso_a0_841x1189mm"), + ("4 x 6 in label (101.6 x 152.4 mm)", "w288h432"), + ("US #10 Envelope (4.125 x 9.5 in)", "Env10"), + ] + ); } #[test] diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index d5d95c881..12a941b15 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -223,6 +223,8 @@ impl PrintDraft { self.handling == Handling::Multiple && self.order == PageOrder::CutStack } + /// The spooler job for this print: the dialog's copies, two-sided and colour settings, the + /// driver options changed from the printer's defaults, and the chosen paper. pub fn job(&self, title: &str) -> spool::Job { spool::Job { printer: self.printer.clone(), From 635b42d9899a671b8e82d50033d970b16643c40f Mon Sep 17 00:00:00 2001 From: Juan Jurado Date: Thu, 22 Oct 2026 18:42:49 -0600 Subject: [PATCH 21/59] M10: ARCH E1 goes out as PWG 5101.1's na_wide-format_30x42in (#3752) 30 x 42 in is `na_wide-format_30x42in` in PWG 5101.1-2023 and the IANA IPP registry (checked against both). `na_arch-e1_30x42in` was asked for on the IPP list in 2021 but never registered. CUPS maps the new name to the PPD's 30 x 42 in size (cupsfilter, synthetic PPD), and the paper test pins it and fails on the old name. Doc comments for bad, printers and two paper tests, for the review's docstring check. Review: storytold/pdfcraft#4161 (CodeRabbit). --- crates/automation/src/printing.rs | 3 +++ crates/print/src/lib.rs | 10 +++++----- crates/print/src/tests.rs | 10 +++++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 111d27eec..74e732814 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -22,6 +22,7 @@ fn duplex(value: &str, cut_stack: bool) -> Result> { }) } +/// An invalid-arguments error saying `m`. fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) } @@ -75,6 +76,8 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option Result { let list: Vec = spool::printers() .into_iter() diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 75b6fdf1f..d85e766f1 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -309,10 +309,10 @@ pub const LABEL_PAPER: usize = 19; /// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out /// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes /// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). -/// The rest use PWG 5101.1 names (for ARCH E1, which 5101.1 lacks, the IANA registry's), spelled -/// exactly: a driverless printer may take only the names it advertises. They carry their -/// dimensions, so CUPS feeds the PPD size of those dimensions whatever the driver calls it -/// (`ARCHD`, `AnsiD`) (#3752). +/// The rest use PWG 5101.1 names (ARCH E1 is its `wide-format` size), spelled exactly as the +/// spec and the IANA registry have them: a driverless printer may take only the names it +/// advertises. They carry their dimensions, so CUPS feeds the PPD size of those dimensions +/// whatever the driver calls it (`ARCHD`, `AnsiD`) (#3752). pub fn cups_media(index: usize) -> Option<&'static str> { Some(match PAPERS.get(index)?.0 { "US Letter" => "Letter", @@ -326,7 +326,7 @@ pub fn cups_media(index: usize) -> Option<&'static str> { "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", - "ARCH E1 (30 x 42 in)" => "na_arch-e1_30x42in", + "ARCH E1 (30 x 42 in)" => "na_wide-format_30x42in", "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", "ANSI C (17 x 22 in)" => "na_c_17x22in", "ANSI D (22 x 34 in)" => "na_d_22x34in", diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index e7777fa1a..d93bdf745 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -661,6 +661,8 @@ fn architectural_sheets_preserve_full_scale() { assert!(fit[0].placed[0].matrix.0[0] < 1.0, "Fit adds margins and must not masquerade as full scale"); } +/// A sheet matches a preset either way round and rounded to whole points, but not half a point off; +/// empty, negative, infinite, NaN and custom sizes match none. #[test] fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() { for (i, (_, (w, h))) in PAPERS.iter().enumerate() { @@ -702,7 +704,8 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { assert_eq!((cups_media(PAPERS.len()), media_option(PAPERS.len())), (None, None)); // The names themselves, spelled as PWG 5101.1 has them: a driverless printer may take only the // names it advertises, so one CUPS can merely parse (ARCH A was `arch_a_9x12in`) is not enough. - // ARCH E1 isn't in 5101.1; its name is the IANA registry's. + // ARCH E1 is 5101.1's `wide-format` size: `na_arch-e1_30x42in` was asked for once and never + // registered. let names: Vec<(&str, &str)> = PAPERS.iter().enumerate().map(|(i, (name, _))| (*name, cups_media(i).unwrap_or_default())).collect(); assert_eq!( names, @@ -718,7 +721,7 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { ("ARCH B (12 x 18 in)", "na_arch-b_12x18in"), ("ARCH C (18 x 24 in)", "na_arch-c_18x24in"), ("ARCH D (24 x 36 in)", "na_arch-d_24x36in"), - ("ARCH E1 (30 x 42 in)", "na_arch-e1_30x42in"), + ("ARCH E1 (30 x 42 in)", "na_wide-format_30x42in"), ("ARCH E (36 x 48 in)", "na_arch-e_36x48in"), ("ANSI C (17 x 22 in)", "na_c_17x22in"), ("ANSI D (22 x 34 in)", "na_d_22x34in"), @@ -732,9 +735,10 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { ); } +/// #2734: the US #10 envelope (4.125 × 9.5 in) is offered, fed by its PPD name and laid out at +/// its own size. #[test] fn us_number_10_envelope_is_offered_and_fed_by_name() { - // 4.125 × 9.5 in (#2734). let index = matching_paper((684.0, 297.0)).unwrap(); assert_eq!(PAPERS[index].0, "US #10 Envelope (4.125 x 9.5 in)"); assert_eq!(PAPERS[index].1, (297.0, 684.0)); From d6ba2413ba556894c67f89019f4da27f1cdd6950 Mon Sep 17 00:00:00 2001 From: mmmaarian03-crypto <253881360+mmmaarian03-crypto@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:37:35 +0300 Subject: [PATCH 22/59] Comments: the composer hides while its comment is scrolled away (#4021) The floating comment card (new note, text box, edit comment) was an egui Area constrained to the whole window. When the page scrolled its comment under the mode bar, the card was pushed back into the window and sat over the mode bar and toolbar until the whole page left the screen. It now shows only while its anchor is inside the document view, and is kept inside that view. While the comment is out of sight the draft is kept, and the editor takes the focus again when it comes back. Test: tests/comments.rs the_composer_hides_while_its_comment_is_scrolled_away scrolls a sticky note's anchor above the document with its page still on screen; it fails without the change (the card sat at y=43, over the toolbar, with the document starting at y=134) and passes with it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/comments.rs | 10 +++++++++- crates/ui-egui/tests/it/comments.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index d80f988b7..df2be3780 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1564,6 +1564,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, }; let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true, hidden: false, redact_mark: ([0.89, 0.13, 0.13], 1.0) }; let anchor = cx.to_screen(c.at); + let viewport = view.viewport_rect(); let t = Tokens::get(ctx); let mut post = false; let mut cancel = false; @@ -1576,8 +1577,15 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ComposerKind::Replace => "Replacement text", ComposerKind::Edit(_) => "Edit comment", }; + // Its comment scrolled out of the document: keep the draft until it is back, rather than + // pushing the card into the window over the mode bar and toolbar (#4021). + if !viewport.contains(anchor) { + c.focus = true; + return None; + } let pos = pos2(anchor.x + 12.0, anchor.y); - egui::Area::new(egui::Id::new(("comment-composer", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + let area = egui::Area::new(egui::Id::new(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport); + area.fixed_pos(pos).show(ctx, |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| { ui.set_width(260.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6194c7516..336f7d215 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -675,6 +675,34 @@ fn sticky_note_composer_posts_and_returns_to_select() { assert_eq!(comments(&h).len(), 1); } +/// The composer leaves with its comment when the page scrolls it out of the document, and comes +/// back with the draft (#4021). It used to be pushed back into the window, over the mode bar and +/// the toolbar, while its comment was out of sight. +#[test] +fn the_composer_hides_while_its_comment_is_scrolled_away() { + let mut h = harness(|app| app.set_option("quick", "note").unwrap()); + click_pt(&mut h, (250.0, 180.0)); + field(&h, "Add a comment").type_text("Draft"); + h.run_steps(2); + let editors = |h: &Harness<'static, PdfCraftApp>| -> Vec { + h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).map(|n| n.rect()).collect() + }; + let viewport = h.state().views[0].viewport_rect(); + assert!(editors(&h).iter().any(|r| viewport.contains(r.center())), "the composer opens in the document"); + // The page scrolled up: it still shows, the comment near its top doesn't. + h.state_mut().views[0].goto = Some((0, 0.4)); + h.run_steps(4); + assert!(h.state().views[0].page_screen_rect(0).is_some_and(|r| r.bottom() > viewport.top()), "page 1 still shows"); + assert!(at(&h, 250.0, 180.0).y < viewport.top(), "the comment is scrolled away"); + let shown = editors(&h); + assert!(shown.is_empty(), "the composer stays with its comment, not over the toolbar: {shown:?} (document {viewport:?})"); + // Back again, the draft is still there. + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + assert!(editors(&h).iter().any(|r| viewport.contains(r.center())), "the composer is back"); + assert_eq!(h.state().views[0].comments.composer.as_ref().map(|c| c.text.as_str()), Some("Draft")); +} + /// The rectangle of the comment whose text starts with `text`. fn rect_of(h: &Harness<'static, PdfCraftApp>, text: &str) -> [f32; 4] { let c = comments(h); From cdc0834f349e66732d1d6b6b3a7e5779b4b2a53a Mon Sep 17 00:00:00 2001 From: mmmaarian03-crypto <253881360+mmmaarian03-crypto@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:42:54 +0300 Subject: [PATCH 23/59] Comments: the composer refocuses after its page was off screen (#4021) When the whole page left the screen, `composer` returned before it set `focus`, so the editor didn't take the keyboard again when the page came back (review on #4095). That path now asks for the focus too. Test `the_composer_takes_the_keyboard_back_after_its_page_was_off_screen` scrolls page 1 off the screen and back; it fails without the change. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/comments.rs | 5 ++++- crates/ui-egui/tests/it/comments.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index df2be3780..3cdbb953d 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1559,7 +1559,10 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, let c = view.comments.composer.as_ref()?; let page = c.page; let Some(xf) = view.page_xform(page) else { - // Scrolled away: keep the draft until the page is back. + // Scrolled away: keep the draft until the page is back, and give it the keyboard then. + if let Some(c) = view.comments.composer.as_mut() { + c.focus = true; + } return None; }; let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true, hidden: false, redact_mark: ([0.89, 0.13, 0.13], 1.0) }; diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 336f7d215..d97c09fbf 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -703,6 +703,28 @@ fn the_composer_hides_while_its_comment_is_scrolled_away() { assert_eq!(h.state().views[0].comments.composer.as_ref().map(|c| c.text.as_str()), Some("Draft")); } +/// With its whole page scrolled off the screen the composer keeps its draft, and the editor takes +/// the keyboard again when the page comes back (#4021). +#[test] +fn the_composer_takes_the_keyboard_back_after_its_page_was_off_screen() { + let mut h = harness(|app| app.set_option("quick", "note").unwrap()); + click_pt(&mut h, (250.0, 180.0)); + field(&h, "Add a comment").type_text("Draft"); + h.run_steps(2); + let editor_focused = + |h: &Harness<'static, PdfCraftApp>| h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).any(|n| n.is_focused()); + assert!(editor_focused(&h), "the new note's editor has the keyboard"); + let viewport = h.state().views[0].viewport_rect(); + h.state_mut().views[0].goto = Some((1, 0.6)); + h.run_steps(4); + assert!(h.state().views[0].page_screen_rect(0).is_none_or(|r| r.bottom() < viewport.top()), "page 1 is off the screen"); + assert!(!editor_focused(&h), "nothing to type into while the page is away"); + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + assert_eq!(h.state().views[0].comments.composer.as_ref().map(|c| c.text.as_str()), Some("Draft")); + assert!(editor_focused(&h), "the editor has the keyboard again"); +} + /// The rectangle of the comment whose text starts with `text`. fn rect_of(h: &Harness<'static, PdfCraftApp>, text: &str) -> [f32; 4] { let c = comments(h); From 12b5e670e40ff321c7a00231a09b333205b8a15f Mon Sep 17 00:00:00 2001 From: so-sai Date: Thu, 22 Oct 2026 13:31:02 +0700 Subject: [PATCH 24/59] feat(ui): add 'Split document...' to page thumbnail context menus --- crates/ui-egui/src/canvas.rs | 6 +++ crates/ui-egui/src/editing.rs | 4 ++ crates/ui-egui/src/panels.rs | 7 +++ crates/ui-egui/tests/it/editing.rs | 82 ++++++++++++++++++++++++++++++ crates/ui-egui/tests/it/viewer.rs | 2 +- parity/acrobat-features.toml | 6 +-- 6 files changed, 103 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8df86fb3..df0c89130 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -665,6 +665,8 @@ pub enum ViewAction { Save, Extract, Split, + /// Split before each selected page: opens Split on that mode. + SplitBeforeSelected, /// Copy the selected pages (Cut also deletes them). CopyPages { cut: bool, @@ -5488,6 +5490,10 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool view.pending_action = Some(ViewAction::Extract); ui.close(); } + if ui.button(tl!("Split document…")).clicked() { + view.pending_action = Some(ViewAction::SplitBeforeSelected); + ui.close(); + } if ui.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() { view.pending_action = Some(ViewAction::ReplacePages); ui.close(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 5b821b638..99f0534ab 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -265,6 +265,10 @@ impl PdfCraftApp { } Some(crate::canvas::ViewAction::Extract) => self.open_extract_dialog(), Some(crate::canvas::ViewAction::Split) => self.dialog = Some(crate::Dialog::Split), + Some(crate::canvas::ViewAction::SplitBeforeSelected) => { + self.split_draft.mode = crate::SplitMode::Selection; + self.dialog = Some(crate::Dialog::Split); + } Some(crate::canvas::ViewAction::CopyPages { cut }) => self.copy_pages(cut), Some(crate::canvas::ViewAction::PastePages) => self.paste_pages(), Some(crate::canvas::ViewAction::EnhanceCamera) => self.enhance_camera(i), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index edfe63ebc..d36f4cf19 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1363,6 +1363,13 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie view.pending_action = Some(crate::canvas::ViewAction::Extract); ui.close(); } + if ui.button(tl!("Split document…")).clicked() { + if view.selected.is_empty() { + view.select_pages(&[i]); + } + view.pending_action = Some(crate::canvas::ViewAction::SplitBeforeSelected); + ui.close(); + } if ui.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() { view.pending_action = Some(crate::canvas::ViewAction::ReplacePages); ui.close(); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 71ba9ccda..94e6d62bc 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -2241,6 +2241,88 @@ fn split_before_selected_pages() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn split_from_organize_grid_context_menu() { + let dir = temp_path("split-org-ctx"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = organize(4); + h.state_mut().export_dir_override = Some(dir.to_string_lossy().into_owned()); + h.get_by_label("Page 3").click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection); + h.get_by_label_contains("Creates 2 files from 4 pages"); + h.get_all_by_label("Split").last().unwrap().click(); + h.run_steps(3); + let mut names: Vec = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + assert_eq!(names, ["doc (pages 1-2).pdf", "doc (pages 3-4).pdf"]); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn split_from_pages_panel_context_menu() { + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + let thumb = h.get_all_by_label("Page 3").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap(); + thumb.click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection); + h.get_by_label_contains("Creates 2 files from 4 pages"); +} + +#[test] +fn split_from_pages_panel_context_menu_on_current_page_without_selection() { + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + // Navigate to Page 2 (0-indexed 1) so it is current, but keep selected empty + h.state_mut().views[0].go_to_page(1); + h.state_mut().views[0].selected.clear(); + h.run_steps(2); + assert_eq!(h.state().views[0].current, 1); + assert!(h.state().views[0].selected.is_empty()); + let thumb = h.get_all_by_label("Page 2").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap(); + thumb.click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection); + h.get_by_label_contains("Creates 2 files from 4 pages"); +} + +#[test] +fn split_from_pages_panel_context_menu_on_page_one_falls_back_to_pages() { + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + let thumb = h.get_all_by_label("Page 1").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap(); + thumb.click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Pages); +} + +#[test] +fn split_from_pages_panel_context_menu_with_multi_selection() { + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + // Select pages 2 and 4 (indices 1 and 3) + h.state_mut().views[0].select_pages(&[1, 3]); + h.run_steps(2); + let thumb = h.get_all_by_label("Page 2").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap(); + thumb.click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection); + h.get_by_label_contains("Creates 3 files from 4 pages"); +} + // ── Encrypted documents ─────────────────────────────────────────────────────────────────────── fn protected(user: &str, owner: &str, permissions: i32) -> Vec { diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index a3c86fc52..e4a18ba5e 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -1393,7 +1393,7 @@ fn pages_panel_thumbnails_have_a_context_menu() { h.run_steps(2); // The page right-clicked becomes the selection the menu acts on. assert_eq!(h.state().views[0].target_pages(), vec![2]); - for item in ["Extract pages", "Cut", "Paste after"] { + for item in ["Extract pages", "Split document…", "Cut", "Paste after"] { h.get_by_label(item); } h.get_by_label("Copy").click(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6a2d0ccf9..23c6d5fed 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1274,8 +1274,8 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"] -notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." +evidence = ["crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::split_from_pages_panel_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"] +notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Split document…, Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." [[feature]] id = "view.bookmarks-panel" @@ -2920,7 +2920,7 @@ milestone = "M4" status = "shipped" commands = ["page.split"] tools = ["doc_split"] -evidence = ["crates/ui-egui/tests/it/editing.rs::split_before_selected_pages"] +evidence = ["crates/ui-egui/tests/it/editing.rs::split_before_selected_pages", "crates/ui-egui/tests/it/editing.rs::split_from_organize_grid_context_menu"] notes = "" [[feature]] From 64d8c20584f2cf08c91374ba399a289ecdb1f4fd Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:01:23 +0300 Subject: [PATCH 25/59] Leave the page grid when the Crop tool is chosen Crop is only listed in the Organize pages panel, which is shown together with the thumbnail grid; activating it there must bring the document view back, otherwise the tool is selected but no page is on screen to drag a crop rectangle on. In the "page.crop" command arm, close the organize grid of the active view before arming the Crop tool, the way opening a page from the grid does. The left panel then falls back to the workspace panel on the next frame, as it already does when the grid closes by other means. Test: the_crop_tool_leaves_the_page_grid_so_a_page_can_be_cropped failed before the change and passes after. --- crates/ui-egui/src/commands.rs | 3 +++ crates/ui-egui/tests/it/pageboxes.rs | 29 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 92bc0b360..f908cd423 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -669,6 +669,9 @@ impl PdfCraftApp { } "page.crop" => { self.quick_tool = crate::QuickTool::Crop; + if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { + v.organize = false; + } self.notify_tr("Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes"); } "page.boxes" => { diff --git a/crates/ui-egui/tests/it/pageboxes.rs b/crates/ui-egui/tests/it/pageboxes.rs index 865f12770..2e2829e05 100644 --- a/crates/ui-egui/tests/it/pageboxes.rs +++ b/crates/ui-egui/tests/it/pageboxes.rs @@ -90,3 +90,32 @@ fn duplicate_pages_copies_the_current_page() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().info.pages.len(), 3); } + +#[test] +fn the_crop_tool_leaves_the_page_grid_so_a_page_can_be_cropped() { + let mut h = harness(); + // The Organize panel's thumbnail grid is on screen for this view: choosing + // "Crop pages" from it must leave the grid so the canvas handles a drag on + // a page instead of returning early to organize_grid. + h.state_mut().views[0].organize = true; + assert!(h.state_mut().execute("page.crop")); + assert_eq!(h.state().quick_tool, QuickTool::Crop); + assert!(!h.state().views[0].organize, "\"page.crop\" must clear view.organize"); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).unwrap(); + // The middle half of the page in both directions. + let (a, b) = (r.min + r.size() * 0.25, r.min + r.size() * 0.75); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(b); + h.run_steps(4); + let (w, hh) = size(&h, 0); + assert!((w - 150.0).abs() < 2.0 && (hh - 200.0).abs() < 2.0, "{w}×{hh}"); + assert_eq!(size(&h, 1), (300.0, 400.0)); +} From cefbefbef933c111231bb9878e57686428ed0882 Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Fri, 23 Oct 2026 11:25:36 +0200 Subject: [PATCH 26/59] deps: ctutils 0.4.5 for RUSTSEC-2026-0385 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47d73be44..9129e85d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1291,9 +1291,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", "subtle", From 07f0faf4d582dc828a8e9bd0c983bcb17a635287 Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Fri, 23 Oct 2026 12:13:07 +0200 Subject: [PATCH 27/59] tests: the NoRotate test's annotation has main's points field too --- crates/render/src/inspect.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 24564ca44..e4a3fb041 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -2356,6 +2356,7 @@ mod tests { intent: None, fill_sign: false, no_rotate: true, + points: Vec::new(), }; let rect = note.rect.map(f64::from); for rotation in [0u16, 90, 180, 270, 45] { From 3b9cfe1d52aa1053199a784260b42be82fae0b1a Mon Sep 17 00:00:00 2001 From: Jayesh-Prajapati9 Date: Fri, 23 Oct 2026 18:32:33 +0530 Subject: [PATCH 28/59] Tests: UI tests wait for background work by time, not frame count (#4186) A frame count is not a time: on a busy runner the worker had not finished the open, or the page text had not arrived, within 30 frames or 50 hover rounds. support::step_until (#[must_use]) steps frames until a condition holds or 30 s pass, sleeping briefly between frames, and says whether it held; each test asserts on that with its own message. - control_open_replies_pending_while_the_worker_opens_and_focuses_once_it_has and mode_tabs_keep_the_hand_over_page_text_scrolled_under_them wait with it, and so does control.rs call() for its reply. - support::settle waits for every pending render and fails if the pages are not rendered within 30 s. It replaces all the render-pending settle loops (40 to 3000 frames) and the local settle/wait_for_pages wrappers, except text_select's double-click wait, which is held to 8 frames on purpose to stay inside egui's multi-click window, and view.rs's loops that already wait by a deadline. - wait_until is built on step_until, keeping its diagnostic message. - tabbing_back_to_the_same_field_then_saving_keeps_the_committed_value already waited 30 s for its file. It failed because its "same-frame" case shared its scratch directory with typing_and_saving_in_the_same_frame_saves_the_last_keystrokes, which runs in the same test process (since ee1ee704, #1045) and deleted the directory mid-save. forms' and pickers' scratch() now give every call a directory of its own. Fixes #4186 --- crates/ui-egui/tests/it/bookmark_position.rs | 21 ++---- crates/ui-egui/tests/it/comments.rs | 24 ++----- crates/ui-egui/tests/it/content.rs | 34 ++------- crates/ui-egui/tests/it/control.rs | 27 ++++---- crates/ui-egui/tests/it/editing.rs | 72 ++++++-------------- crates/ui-egui/tests/it/forms.rs | 55 ++++----------- crates/ui-egui/tests/it/object_selection.rs | 8 +-- crates/ui-egui/tests/it/ocr.rs | 8 +-- crates/ui-egui/tests/it/pickers.rs | 7 +- crates/ui-egui/tests/it/prepare.rs | 8 +-- crates/ui-egui/tests/it/redact.rs | 8 +-- crates/ui-egui/tests/it/support/mod.rs | 44 +++++++++--- crates/ui-egui/tests/it/ui.rs | 30 +++----- crates/ui-egui/tests/it/view.rs | 48 ++----------- crates/ui-egui/tests/it/viewer.rs | 16 +---- crates/ui-egui/tests/it/xfa.rs | 15 +--- 16 files changed, 127 insertions(+), 298 deletions(-) diff --git a/crates/ui-egui/tests/it/bookmark_position.rs b/crates/ui-egui/tests/it/bookmark_position.rs index 03fd8b50a..09fd1d4be 100644 --- a/crates/ui-egui/tests/it/bookmark_position.rs +++ b/crates/ui-egui/tests/it/bookmark_position.rs @@ -55,21 +55,10 @@ fn harness(rotate: u16) -> Harness<'static, PdfCraftApp> { app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); h } -/// Run frames until rendering has finished and the layout holds still. -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..200 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - /// Make a bookmark with ⌘B, as the Bookmarks button does, and say what it goes to. fn new_bookmark(h: &mut Harness<'static, PdfCraftApp>) -> (Option, DestView) { h.key_press_modifiers(Modifiers::COMMAND, Key::B); @@ -103,7 +92,7 @@ fn a_new_bookmark_goes_to_the_top_of_the_window_not_the_top_of_the_page() { h.state_mut().apply_edit(Edit::AddBookmark { parent: vec![], index: 0, title: "Mid".into(), page: 1, left: None, top: Some(250.0) }); h.run_steps(3); h.get_by_label("Mid").click(); - settle(&mut h); + crate::support::settle(&mut h); let (page, view) = new_bookmark(&mut h); assert_eq!(page, Some(1), "the page at the top of the window"); let (left, top) = xyz(view); @@ -151,7 +140,7 @@ fn a_bookmark_made_on_a_turned_page_pins_the_axis_the_window_scrolls_along() { fn rotate_view(h: &mut Harness<'static, PdfCraftApp>, degrees: u16) { h.state_mut().views[0].rotation = degrees; h.run_steps(3); - settle(h); + crate::support::settle(h); } #[test] @@ -216,12 +205,12 @@ fn a_bookmark_in_a_spread_of_unequal_pages_goes_to_the_page_crossing_the_top_of_ }); h.state_mut().views[0].layout = PageLayout::TwoUp; h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); // 20 pt below page 2's top is at the window's top, where page 1 has not started. h.state_mut().apply_edit(Edit::AddBookmark { parent: vec![], index: 0, title: "Right".into(), page: 1, left: None, top: Some(380.0) }); h.run_steps(3); h.get_by_label("Right").click(); - settle(&mut h); + crate::support::settle(&mut h); let (page, view) = new_bookmark(&mut h); assert_eq!(page, Some(1), "page 2 crosses the top of the window; page 1 starts below it"); close(xyz(view).1, 380.0, "top"); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6194c7516..68b41ac98 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -40,20 +40,10 @@ fn harness_with_bytes(bytes: Vec, setup: impl FnOnce(&mut PdfCraftApp) + 'st app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); h } -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..200 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - /// A point on page 1 in PDF user space → screen (no rotation; MediaBox 300×200). fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 { let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); @@ -146,7 +136,7 @@ fn sidebar_comment_centres_the_annotation_at_high_zoom() { && let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { let _gpu = crate::gpu(); - settle(&mut h); + crate::support::settle(&mut h); h.render().unwrap().save(format!("{dir}/comment-centred.png")).unwrap(); } } @@ -754,7 +744,7 @@ fn a_dragged_text_box_follows_a_rotated_view() { } h.state_mut().set_option("zoom", "150").unwrap(); h.run_steps(3); - settle(&mut h); + crate::support::settle(&mut h); let screen = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| { let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); let (u, v) = (x / 300.0, (200.0 - y) / 200.0); @@ -1726,7 +1716,7 @@ fn attachment_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); h } @@ -1915,7 +1905,7 @@ fn hovering_a_comment_shows_its_author_and_text() { app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); assert!(h.query_by_label_contains("Check this figure").is_none(), "no popup before hovering"); let p = at(&h, 100.0, 85.0); h.hover_at(p); @@ -1945,7 +1935,7 @@ fn preference_hides_author_only_highlight_hover_previews() { app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); let p = at(&h, 100.0, 85.0); // By default, author-only hover is shown. @@ -1992,7 +1982,7 @@ fn markup_hover_harness(pdf: Vec, view_rotation: u16) -> Harness<'static, Pd app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); h } diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 515e3e04f..dc13bdb61 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -28,13 +28,7 @@ fn harness_bytes_with_step_dt(bytes: Vec, step_dt: f32) -> Harness<'static, app.set_option("zoom", "150").unwrap(); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } @@ -161,13 +155,7 @@ fn text_tool_deleting_a_draft_hides_the_old_added_glyphs_and_cancel_restores_the click(&mut h, point); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); h.key_press(egui::Key::Backspace); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert!(h.state().views[0].content.draft.as_ref().unwrap().text.is_empty()); let shot = h.render().unwrap(); for x in 105..140 { @@ -795,18 +783,6 @@ fn red(pixels: &image::RgbaImage, r: egui::Rect) -> usize { .count() } -/// Steps until no render is pending. -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..200 { - h.run_steps(1); - if !h.state().render_pending() { - return; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - panic!("the page did not finish rendering"); -} - /// An added picture resized by a corner is drawn at its new size while dragged and after the /// release until the page is rendered again. It used to stay at its old size under the outline for /// the whole drag, and to show there again after the release. @@ -826,7 +802,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { h.state_mut().add_image("red.png".into(), png); h.run_steps(3); assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the picture is selected"); - settle(&mut h); + crate::support::settle(&mut h); let rect = added(&h)[0].content.rect(); let r = item_on_screen(&h, rect); // The lower right corner to the middle: half the size, the upper left corner kept. @@ -841,7 +817,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { h.hover_at(grip + (to - grip) * (k as f32 / 4.0)); h.run_steps(1); } - settle(&mut h); + crate::support::settle(&mut h); let pixels = h.render().unwrap(); assert!(red(&pixels, kept) > 100, "while dragged, the picture is drawn at its new size"); assert_eq!(red(&pixels, gone), 0, "while dragged, the picture shows at its old size"); @@ -856,7 +832,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { } assert_ne!(added(&h)[0].content.rect(), rect, "the resize is applied"); h.state_mut().views[0].hold_rasters = false; - settle(&mut h); + crate::support::settle(&mut h); h.run_steps(2); let pixels = h.render().unwrap(); assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size"); diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 54bfe2fa3..ec29ecc97 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -73,13 +73,15 @@ fn harness_pages_at(pages: usize, width: f32, scale: f32) -> (Harness<'static, P /// Send a request and run frames until it is answered. fn call(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Reply { let rx = c.send(method, params); - for _ in 0..30 { - h.step(); - if let Ok(r) = rx.try_recv() { - return r; - } + let mut reply = None; + let answered = crate::support::step_until(h, |_| { + reply = rx.try_recv().ok(); + reply.is_some() + }); + match reply { + Some(r) if answered => r, + _ => panic!("{method}: no reply in 30 s"), } - panic!("{method}: no reply after 30 frames"); } fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Value { @@ -1674,15 +1676,12 @@ fn control_open_replies_pending_while_the_worker_opens_and_focuses_once_it_has() // The worker completes the open on later frames; the tab shows, and only then does a // repeated open focus it instead of reporting another pending open. let mut documents = 0; - for _ in 0..30 { - h.run_steps(1); - let state = ok(&mut h, &c, "ui.state", json!({})); + let opened = crate::support::step_until(&mut h, |h| { + let state = ok(h, &c, "ui.state", json!({})); documents = state["documents"].as_array().map(|d| d.len()).unwrap_or_default(); - if documents == 1 { - break; - } - } - assert_eq!(documents, 1, "the pending open completed into a tab"); + documents == 1 + }); + assert!(opened, "the pending open completed into a tab: {documents} tabs"); let again = ok(&mut h, &c, "ui.open", json!({ "path": a })); assert_eq!(again["focused"], true, "{again}"); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 71ba9ccda..7713a8e2f 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -2826,13 +2826,7 @@ fn deleting_a_draft_does_not_show_the_original_text_underneath() { h.drop_at(at); h.run_steps(3); h.state_mut().views[0].line_editor.as_mut().unwrap().text.clear(); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let shot = h.render().unwrap(); if let Ok(path) = std::env::var("PDFCRAFT_TEXT_PREVIEW_SHOT") { shot.save(path).unwrap(); @@ -2931,13 +2925,7 @@ fn opening_a_paragraph_leaves_the_page_as_it_is() { }); h.run_steps(4); assert!(h.state_mut().execute("edit.edit_text")); - for _ in 0..100 { - h.run_steps(1); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let page = h.state().views[0].page_screen_rect(0).expect("on screen"); let k = page.width() / 300.0; let block = h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rect; @@ -3008,13 +2996,7 @@ fn a_quick_click_into_another_paragraph_keeps_both_in_place() { }; let (top, bottom) = (screen(&blocks[0]), screen(&blocks[1])); click_at(&mut h, top.center()); - for _ in 0..100 { - h.run_steps(1); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("Top")); let ink = |pixels: &image::RgbaImage, r: egui::Rect| { (r.top() as u32..r.bottom() as u32) @@ -3974,14 +3956,13 @@ fn save_paragraph_drag(h: &mut Harness<'static, PdfCraftApp>, name: &str, origin fn paragraph_drag_shot(h: &mut Harness<'static, PdfCraftApp>, name: &str) { if let Ok(directory) = std::env::var("PDFCRAFT_PARAGRAPH_DRAG_SHOTS") { - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert!(!h.state().render_pending(), "the screenshot must show the current document generation"); + assert!( + crate::support::step_until(h, |h| { + h.run_steps(1); + !h.state().render_pending() + }), + "the screenshot must show the current document generation" + ); let directory = std::path::Path::new(&directory); std::fs::create_dir_all(directory).unwrap(); h.render().unwrap().save(directory.join(name)).unwrap(); @@ -5131,14 +5112,13 @@ fn image_pixels_follow_the_frame_while_a_corner_is_dragged() { h.run_steps(4); // The dragged pixels are the page raster's: wait for it, as a slow runner may not have it yet // (the drag then drew no picture at all). - for _ in 0..400 { - h.run_steps(1); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert!(!h.state().render_pending(), "the page raster arrives"); + assert!( + crate::support::step_until(&mut h, |h| { + h.run_steps(1); + !h.state().render_pending() + }), + "the page raster arrives" + ); let page = h.state().views[0].page_screen_rect(0).unwrap(); let at = |x: f32, y: f32| egui::pos2(page.left() + x / 200.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height()); let p = at(95.0, 195.0); @@ -5837,13 +5817,7 @@ fn a_paragraph_running_off_its_page_edits_without_covering_the_next_page() { h.drag_at(p); h.run_steps(1); h.drop_at(p); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert!( h.state().views[0].line_editor.as_ref().is_some_and(|e| e.page == 0 && e.text.contains("line 50")), "the paragraph's off-page lines are in it" @@ -5932,15 +5906,13 @@ fn mode_tabs_keep_the_hand_over_page_text_scrolled_under_them() { h.run_steps(4); let top = h.state().views[0].page_screen_rect(0).expect("on screen").top(); let mut cursor = egui::CursorIcon::Default; - for _ in 0..50 { + let text_cursor = crate::support::step_until(&mut h, |h| { h.hover_at(text.center() + egui::vec2(0.0, top)); h.run_steps(2); cursor = h.output().platform_output.cursor_icon; - if cursor == egui::CursorIcon::Text { - break; - } - } - assert_eq!(cursor, egui::CursorIcon::Text); + cursor == egui::CursorIcon::Text + }); + assert!(text_cursor, "the I-beam over page text, not {cursor:?}"); assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB); } diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 230addd65..676a16db4 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -14,13 +14,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { app.set_option("zoom", "150").unwrap(); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } @@ -500,8 +494,13 @@ fn wait_for_file(h: &mut Harness<'static, PdfCraftApp>, path: &std::path::Path) h.run_steps(1); } +/// A fresh directory of its own for each call. The tests share one process and run in parallel, +/// so a name alone is not enough: two tests that both used "same-frame" deleted each other's +/// directory mid-save, and the save then never wrote its file (#4186). fn scratch(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("pdfcraft-forms-{name}-{}", std::process::id())); + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("pdfcraft-forms-{name}-{}-{n}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir @@ -1029,13 +1028,7 @@ fn calculated_field_and_script_side_effect_on_another_page_re_render() { event: "calculate".into(), script: Some("event.value = 'from_' + getField('name').value;".into()), })); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); click_field(&mut h, "name", 0); h.event(egui::Event::Text("hello".into())); h.run_steps(2); @@ -1043,13 +1036,7 @@ fn calculated_field_and_script_side_effect_on_another_page_re_render() { h.run_steps(2); assert_eq!(value(&h, "page2_field"), ["from_hello"]); // Wait for all re-renders to complete. - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert_eq!(value(&h, "name"), ["hello"]); assert_eq!(value(&h, "page2_field"), ["from_hello"]); } @@ -1065,13 +1052,7 @@ fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() { h.key_press(egui::Key::Tab); h.run_steps(2); assert_eq!(value(&h, "name"), ["GoodVal"]); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0))); // 2. Rejected edit: typing rejected by validate script leaves unbaked clean. @@ -1091,13 +1072,7 @@ fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() { // 3. Zoom then edit: zoom to another scale, let tiles settle, then edit. h.state_mut().views[0].zoom_step(true); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); click_field(&mut h, "name", 0); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text("ZoomedVal".into())); @@ -1105,13 +1080,7 @@ fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() { h.key_press(egui::Key::Tab); h.run_steps(2); assert_eq!(value(&h, "name"), ["ZoomedVal"]); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0))); } diff --git a/crates/ui-egui/tests/it/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs index 6e428ad74..fc2a8e4fe 100644 --- a/crates/ui-egui/tests/it/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -73,13 +73,7 @@ fn harness_document(clipping: bool, background: bool) -> Harness<'static, PdfCra assert!(app.execute("edit.edit_text")); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } diff --git a/crates/ui-egui/tests/it/ocr.rs b/crates/ui-egui/tests/it/ocr.rs index 20fb73344..7f2e3bdc5 100644 --- a/crates/ui-egui/tests/it/ocr.rs +++ b/crates/ui-egui/tests/it/ocr.rs @@ -133,13 +133,7 @@ fn an_ocr_word_opens_for_editing_and_stays_open() { assert!(app.execute("edit.edit_text")); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let (r, k) = { let app = h.state(); let d = app.session.get(app.views[0].id).unwrap(); diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index e4ce56d7c..bbab087d9 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -115,9 +115,12 @@ fn a_pick_for_a_document_that_is_no_longer_active_is_dropped() { let _ = std::fs::remove_dir_all(&dir); } -/// A scratch folder for one test. +/// A scratch folder for one test, of its own for each call: the tests share one process and run +/// in parallel, so two that picked the same name would delete each other's folder (#4186). fn scratch(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("pdfcraft-pickers-{name}-{}", std::process::id())); + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("pdfcraft-pickers-{name}-{}-{n}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 106d2d539..bf0f6c68d 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -17,13 +17,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { app.set_option("zoom", "150").unwrap(); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index c2d150e11..3e11861cb 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -14,13 +14,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { app.set_option("zoom", "150").unwrap(); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index c4c0ee060..2e1e11ffb 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -2,21 +2,43 @@ use egui_kittest::Harness; use pdfcraft_ui_egui::PdfCraftApp; -pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) { +/// Step frames until `ready` holds or 30 seconds pass, and say whether it held. For background work +/// (a worker-thread open, page text arriving) that a test then asserts on itself: a frame count is +/// not a time, and a busy runner may not have finished the work within one. Sleeps briefly between +/// frames so the worker threads get the CPU. +#[must_use] +pub fn step_until(h: &mut Harness<'static, PdfCraftApp>, mut ready: impl FnMut(&mut Harness<'static, PdfCraftApp>) -> bool) -> bool { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); loop { h.step(); - if ready(h.state()) { - return; + if ready(h) { + return true; + } + if std::time::Instant::now() >= deadline { + return false; } - assert!( - std::time::Instant::now() < deadline, - "timed out waiting for {what}: views={}, active={:?}, password={:?}, toast={:?}", - h.state().views.len(), - h.state().active, - h.state().password_prompt.as_ref().map(|p| (&p.name, &p.error)), - h.state().toast - ); std::thread::sleep(std::time::Duration::from_millis(5)); } } + +/// Step frames until nothing is waiting to be rendered, and fail the test if that takes over 30 +/// seconds: what a test checks next is on the rendered page. +pub fn settle(h: &mut Harness<'static, PdfCraftApp>) { + let settled = step_until(h, |h| { + h.run_steps(1); + !h.state().render_pending() + }); + assert!(settled, "pages rendered"); +} + +pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) { + let held = step_until(h, |h| ready(h.state())); + assert!( + held, + "timed out waiting for {what}: views={}, active={:?}, password={:?}, toast={:?}", + h.state().views.len(), + h.state().active, + h.state().password_prompt.as_ref().map(|p| (&p.name, &p.error)), + h.state().toast + ); +} diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 6472b7c66..275cdab76 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -451,7 +451,7 @@ fn overflowing_tabs_can_be_reached_by_scrolling() { assert!(h.state().active.is_some_and(|i| i < 19)); if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { let _gpu = crate::gpu(); - settle(&mut h); + crate::support::settle(&mut h); h.render().unwrap().save(format!("{dir}/overflow-tabs.png")).unwrap(); } } @@ -565,7 +565,7 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() { assert!(h.get_by_label_contains("Display theme:").rect().right() <= width); if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { let _gpu = crate::gpu(); - settle(&mut h); + crate::support::settle(&mut h); h.render().unwrap().save(format!("{dir}/tabs-{width}.png")).unwrap(); } } @@ -612,23 +612,13 @@ endstream endobj trailer << /Root 1 0 R >> %%EOF"; -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..200 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - #[test] fn find_counts_matches_across_pages() { let mut h = harness(|app| { app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); app.set_option("find", "brown").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); h.get_by_label_contains("of 2"); let v = &h.state().views[0]; let f = v.find.as_ref().expect("find open"); @@ -641,7 +631,7 @@ fn find_reports_no_matches() { app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); app.set_option("find", "zebra").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); h.get_by_label("No matches"); } @@ -651,7 +641,7 @@ fn drag_selects_text_and_copy_returns_it() { app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); // "The quick brown fox": glyph 4 = 'q', glyph 14 = 'n' of "brown". let (a, b) = { let v = &h.state().views[0]; @@ -738,7 +728,7 @@ fn zoom_keeps_the_point_under_the_cursor_still() { app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); let before = h.state().views[0].glyph_screen_pos(0, 4).expect("glyph on screen"); let z = h.state().views[0].zoom; h.state_mut().views[0].zoom_at(z * 2.0, before); @@ -754,7 +744,7 @@ fn trackpad_pinch_zooms_the_pdf_under_the_pointer() { app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); let before = h.state().views[0].glyph_screen_pos(0, 4).expect("glyph on screen"); let zoom = h.state().views[0].zoom; h.hover_at(before); @@ -781,7 +771,7 @@ fn selection_works_in_every_view_rotation() { // Keep the whole rotated page on screen. app.set_option("zoom", "60").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); let (a, b) = { let v = &h.state().views[0]; (v.glyph_screen_pos(0, 4).expect("glyph"), v.glyph_screen_pos(0, 14).expect("glyph")) @@ -1321,7 +1311,7 @@ fn document_tabs_expose_selection_and_activate_from_keyboard() { } if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { let _gpu = crate::gpu(); - settle(&mut h); + crate::support::settle(&mut h); h.render().unwrap().save(format!("{dir}/focused-tab-{key:?}.png")).unwrap(); } } @@ -1547,7 +1537,7 @@ fn short_window_rail_buttons_scroll_clear_of_page_navigation() { app.open_bytes("short.pdf", None, FIXTURE.to_vec()).unwrap(); app }); - settle(&mut h); + crate::support::settle(&mut h); let rail = |h: &Harness<'static, PdfCraftApp>, label: &str| { h.query_all_by_label(label).map(|n| n.rect()).find(|r| r.left() >= width - 48.0).unwrap_or_else(|| panic!("{label} in the rail")) }; diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index dc1d5585d..3520f0a98 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1266,13 +1266,7 @@ fn highlight_fields_tints_the_field_area() { h.state_mut().set_option("panel", "none").unwrap(); // Highlighting is on by default; start from plain fields. h.state_mut().set_option("fields", "off").unwrap(); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let r = rect(&h, 0).expect("page 1"); // The field spans x 50..250, y 300..330 (PDF space, y up) on a 300×400 page; sample inside it. let at = egui::pos2(r.min.x + r.width() * (60.0 / 300.0), r.min.y + r.height() * (1.0 - 305.0 / 400.0)); @@ -1409,13 +1403,7 @@ fn required_radio_buttons_get_a_round_red_border() { app.set_option("fields", "on").unwrap(); app }); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let r = rect(&h, 0).expect("page 1"); let ppp = h.ctx.pixels_per_point(); let img = h.render().expect("renders"); @@ -1560,13 +1548,7 @@ fn required_fields_get_a_red_border_when_highlighting() { h.state_mut().set_option("fields", "on").unwrap(); let props = pdfcraft_engine::FieldProps { required: Some(true), ..Default::default() }; h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { name: "fullname".into(), props: Box::new(props) }); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let r = rect(&h, 0).expect("page 1"); // Just inside the left edge of the field (x 50, y 300..330 on a 300×400 page). let ppp = h.ctx.pixels_per_point(); @@ -1622,13 +1604,7 @@ trailer << /Root 1 0 R >> /// Mean and largest difference between page 1 on screen and the page rendered directly at the /// view's exact device scale, over the lined area of `page_raster_maps_one_to_one_onto_screen_pixels`. fn compare_page_with_raster(h: &mut Harness<'static, PdfCraftApp>, pdf: &[u8], zoom: f32) -> (f64, u8) { - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(h); let ppp = h.ctx.pixels_per_point(); let r = rect(h, 0).expect("page 1"); let img = h.render().expect("renders"); @@ -1693,16 +1669,6 @@ fn grid_harness(pdf: Vec, zoom: &str) -> Harness<'static, PdfCraftApp> { }) } -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..400 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - /// Detail in the middle of the window: the summed difference between neighbouring pixels. fn centre_detail(h: &mut Harness<'static, PdfCraftApp>) -> u64 { let img = h.render().expect("renders"); @@ -1720,7 +1686,7 @@ fn centre_detail(h: &mut Harness<'static, PdfCraftApp>) -> u64 { fn zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive() { let _gpu = gpu(); let mut h = grid_harness(fine_grid_pdf(), "300"); - settle(&mut h); + crate::support::settle(&mut h); let sharp = centre_detail(&mut h); // The first frame at a new zoom has no tiles for it yet (they are requested at its end). h.state_mut().set_option("zoom", "310").unwrap(); @@ -1736,13 +1702,13 @@ fn zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive() { fn returning_to_a_visited_zoom_reuses_its_cached_tiles() { let _gpu = gpu(); let mut h = grid_harness(fine_grid_pdf(), "300"); - settle(&mut h); + crate::support::settle(&mut h); let before = h.state().views[0].tiles_debug(0); assert!(!before.is_empty(), "the 300 % page should be tiled, but it cached no tiles"); // Zoom right out, where the 1200 pt page fits in one raster and is not tiled at all, so // none of its tiles is demanded... h.state_mut().set_option("zoom", "50").unwrap(); - settle(&mut h); + crate::support::settle(&mut h); // ...then back to the first zoom, which was already rendered sharp. h.state_mut().set_option("zoom", "300").unwrap(); h.step(); diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index a3c86fc52..8052e6a31 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -40,13 +40,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { app }); // Background rendering and text extraction can take seconds on a loaded CI runner (FreeBSD VM). - for _ in 0..3000 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } @@ -1732,13 +1726,7 @@ fn a_picture_inside_form_artwork_under_text_is_selected_and_copied() { app.open_bytes("scan.pdf", None, pdf).unwrap(); app }); - for _ in 0..40 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h.state_mut().system_clipboard = false; let i = h.state().active.unwrap(); let r = h.state().views[i].page_screen_rect(0).expect("on screen"); diff --git a/crates/ui-egui/tests/it/xfa.rs b/crates/ui-egui/tests/it/xfa.rs index e66658b0e..ac03ebd69 100644 --- a/crates/ui-egui/tests/it/xfa.rs +++ b/crates/ui-egui/tests/it/xfa.rs @@ -103,17 +103,6 @@ fn ordinary_forms_and_documents_have_no_xfa_notice() { assert_eq!(xfa(&h), Some(pdfcraft_render::Xfa::Dynamic)); } -/// Run frames until the pages are drawn (fields can be clicked then). -fn wait_for_pages(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..40 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - /// Click the centre of a field's widget. fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) { let p = { @@ -133,7 +122,7 @@ fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) { #[test] fn xfa_buttons_run_their_scripts_in_the_app() { let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); - wait_for_pages(&mut h); + crate::support::settle(&mut h); let names = |h: &Harness<'static, PdfCraftApp>| { let s = h.state(); s.session.get(s.views[0].id).unwrap().form.iter().map(|f| f.name.clone()).collect::>() @@ -189,7 +178,7 @@ fn a_form_whose_scripts_were_turned_off_at_open_says_so() { fn scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back() { let tpl = pdfcraft_xfa::fixtures::scripted_template().replace(r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, RUNAWAY); let mut h = open(pdfcraft_xfa::fixtures::shell(&tpl)); - wait_for_pages(&mut h); + crate::support::settle(&mut h); // The form opens with its scripts on, and the user closes the notice bar. let id = h.state().views[0].id; assert!(!h.state().session.get(id).unwrap().xfa_scripts_off()); From 866a28a93e7bb801d87227c3771b82be73738ef9 Mon Sep 17 00:00:00 2001 From: Jayesh-Prajapati9 Date: Fri, 23 Oct 2026 18:15:48 +0530 Subject: [PATCH 29/59] M14: update ctutils to 0.4.5 for RUSTSEC-2026-0385 The dependency-advisory gate rejects ctutils 0.4.2 (non-constant-time mask generation and u128 comparisons, fixed in 0.4.4). It comes in through crypto-bigint for the signing crates. Only its version and checksum change in Cargo.lock. (cherry picked from commit 1f8811761a08d853be4af0c36d52a551bd274054) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47d73be44..9129e85d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1291,9 +1291,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", "subtle", From 1481eb1ddab1bea1468da2a10c1bcf77c0abac17 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:39:36 +0300 Subject: [PATCH 30/59] Update ctutils to fix RUSTSEC-2026-0385 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47d73be44..2a655a3df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1291,9 +1291,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865" dependencies = [ "cmov", "subtle", From 81e054231cc04124068a95420c050bb9142966fc Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:36:12 +0300 Subject: [PATCH 31/59] organize: format field widget splitting changes --- crates/organize/src/import.rs | 21 ++++++++++++++++++++- crates/organize/src/tests.rs | 17 +++++++++-------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index ef18a121f..ab8c15076 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -38,7 +38,26 @@ const MAX_COPY_DEPTH: usize = 256; /// appearance, placement and page. Field state (name, value, flags, default appearance) moves to /// the new parent field, where the widgets inherit it. const WIDGET_KEYS: &[&[u8]] = &[ - b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"BS", b"Border", b"C", b"CA", b"F", b"H", b"R", b"NM", b"M", b"StructParent", b"StructParents", b"OC", b"Tabs", + b"Type", + b"Subtype", + b"Rect", + b"P", + b"AP", + b"AS", + b"MK", + b"BS", + b"Border", + b"C", + b"CA", + b"F", + b"H", + b"R", + b"NM", + b"M", + b"StructParent", + b"StructParents", + b"OC", + b"Tabs", ]; struct Copier<'a> { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index c1e20a132..00eebaedc 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -681,7 +681,10 @@ fn repeated_page_splits_a_combined_field_widget() { let ps = pages(&out).unwrap(); let widget = |i: usize| -> ObjRef { let list = page_dict(&out, i).get(b"Annots").map(|a| out.resolve(a).as_array().cloned().unwrap_or_default()).unwrap_or_default(); - list.iter().filter_map(|a| a.as_ref()).find(|r| out.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))).expect("a widget annotation") + list.iter() + .filter_map(|a| a.as_ref()) + .find(|r| out.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))) + .expect("a widget annotation") }; let (w1, w2) = (widget(0), widget(1)); assert_ne!(w1, w2, "each copy of the page has its own widget"); @@ -692,7 +695,8 @@ fn repeated_page_splits_a_combined_field_widget() { let f = out.get(parent).as_dict().unwrap().clone(); assert_eq!(f.get(b"T").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("name")); assert_eq!(f.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada")); - let kids: Vec = f.get(b"Kids").and_then(|k| out.resolve(k).as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); + let kids: Vec = + f.get(b"Kids").and_then(|k| out.resolve(k).as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); assert!(kids.contains(&w1) && kids.contains(&w2), "one field holds both widgets: {kids:?}"); let fields: Vec = catalog(&out) .get(b"AcroForm") @@ -747,7 +751,8 @@ fn form_fields_come_along_and_are_registered() { let field = out.resolve(&fields[0]).as_dict().cloned().unwrap(); assert_eq!(field.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada")); // The widget child, not the field, names the page (#1810). - let kid = field.get(b"Kids").map(|k| out.resolve(k)).and_then(|k| k.as_array().cloned()).and_then(|k| k.first().and_then(|k| k.as_ref())).unwrap(); + let kid = + field.get(b"Kids").map(|k| out.resolve(k)).and_then(|k| k.as_array().cloned()).and_then(|k| k.first().and_then(|k| k.as_ref())).unwrap(); assert_eq!(out.get(kid).as_dict().unwrap().reference(b"P"), Some(pages(&out).unwrap()[0].obj)); } @@ -2600,11 +2605,7 @@ fn a_repeated_page_joins_the_renamed_field() { let list = page_dict(&dst, page).get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap(); let widget = list.iter().find_map(|a| a.as_ref()).expect("a widget on the page"); widgets.push(widget); - assert_eq!( - dst.get(widget).as_dict().unwrap().reference(b"Parent"), - renamed.as_ref(), - "page {page}'s widget belongs to the renamed field" - ); + assert_eq!(dst.get(widget).as_dict().unwrap().reference(b"Parent"), renamed.as_ref(), "page {page}'s widget belongs to the renamed field"); } assert_ne!(widgets[0], widgets[1], "each copy has its own widget"); } From 1f41c0ccaaabebf2bdc97c616e6426bfa5ac0674 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:31:13 +0300 Subject: [PATCH 32/59] organize: preserve widget actions and inherited field state --- crates/organize/src/import.rs | 44 ++++++++++++++++++++++++++++------- crates/organize/src/tests.rs | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index ab8c15076..405dc8a5f 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -58,6 +58,7 @@ const WIDGET_KEYS: &[&[u8]] = &[ b"StructParents", b"OC", b"Tabs", + b"A", ]; struct Copier<'a> { @@ -435,7 +436,8 @@ fn import_pages_mapped( { let r = dst.add(Object::Null); let c = copier.copy_value(dst, &resolved, Some(r))?; - let Object::Dict(mut d) = c else { continue }; + let Object::Dict(d) = c else { continue }; + let (_, mut d) = field_and_widget(dst, &d); d.set(b"Parent".to_vec(), Object::Ref(parent)); // The new widget is not a field root of its own. copier.fields.retain(|f| *f != r); @@ -864,14 +866,7 @@ fn add_kid(dst: &mut Document, field: ObjRef, kid: ObjRef) -> Result<(), Organiz /// field stays one. fn split_own_field(dst: &mut Document, fields: &mut [ObjRef], widget: ObjRef) { let Some(d) = dst.get(widget).as_dict().cloned() else { return }; - let (mut field, mut child) = (Dict::new(), Dict::new()); - for (k, v) in d.iter() { - if WIDGET_KEYS.contains(&k.as_slice()) { - child.set(k.clone(), v.clone()); - } else { - field.set(k.clone(), v.clone()); - } - } + let (mut field, mut child) = field_and_widget(dst, &d); let parent = dst.add(Object::Null); field.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(widget)])); child.set(b"Parent".to_vec(), Object::Ref(parent)); @@ -882,6 +877,37 @@ fn split_own_field(dst: &mut Document, fields: &mut [ObjRef], widget: ObjRef) { } } +/// A combined field/widget has two additional-action namespaces (ISO 32000-1 tables 194/196). +/// Partition them as well as the ordinary entries, including when copying a later widget. +fn field_and_widget(doc: &Document, d: &Dict) -> (Dict, Dict) { + let (mut field, mut child) = (Dict::new(), Dict::new()); + for (k, v) in d.iter() { + if k.as_slice() == b"AA" + && let Some(actions) = doc.resolve(v).as_dict().cloned() + { + let (mut field_actions, mut widget_actions) = (Dict::new(), Dict::new()); + for (trigger, action) in actions.iter() { + if [b"E".as_slice(), b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"].contains(&trigger.as_slice()) { + widget_actions.set(trigger.clone(), action.clone()); + } else { + field_actions.set(trigger.clone(), action.clone()); + } + } + if field_actions.iter().next().is_some() { + field.set(k.clone(), Object::Dict(field_actions)); + } + if widget_actions.iter().next().is_some() { + child.set(k.clone(), Object::Dict(widget_actions)); + } + } else if WIDGET_KEYS.contains(&k.as_slice()) { + child.set(k.clone(), v.clone()); + } else { + field.set(k.clone(), v.clone()); + } + } + (field, child) +} + /// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed). fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> { if fields.is_empty() { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 00eebaedc..ad4688e25 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -690,6 +690,9 @@ fn repeated_page_splits_a_combined_field_widget() { assert_ne!(w1, w2, "each copy of the page has its own widget"); for (i, w) in [(0, w1), (1, w2)] { let d = out.get(w).as_dict().unwrap().clone(); + for key in [b"T".as_slice(), b"V", b"FT"] { + assert!(!d.contains(key), "a widget inherits field entries: {key:?}"); + } assert_eq!(d.reference(b"P"), Some(ps[i].obj), "/P names the page holding the widget"); let parent = d.reference(b"Parent").expect("the widget has a parent field"); let f = out.get(parent).as_dict().unwrap().clone(); @@ -712,6 +715,46 @@ fn repeated_page_splits_a_combined_field_widget() { } } +#[test] +fn splitting_a_combined_widget_keeps_annotation_actions_and_field_triggers() { + let mut src = doc_a(); + let page = pages(&src).unwrap()[1].obj; + let list = page_dict(&src, 1).get(b"Annots").and_then(|a| a.as_array()).cloned().unwrap(); + let widget = + list.iter().filter_map(Object::as_ref).find(|r| src.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))).unwrap(); + let mut action = Dict::new(); + action.set(b"S".to_vec(), Object::name("GoTo")); + action.set(b"D".to_vec(), Object::Array(vec![Object::Ref(page), Object::name("Fit")])); + let mut named = Dict::new(); + named.set(b"S".to_vec(), Object::name("Named")); + named.set(b"N".to_vec(), Object::name("NextPage")); + let mut aa = Dict::new(); + aa.set(b"E".to_vec(), Object::Dict(named.clone())); + aa.set(b"K".to_vec(), Object::Dict(named)); + let aa = src.add(Object::Dict(aa)); + src.update_dict(widget, |d| { + d.set(b"A".to_vec(), Object::Dict(action)); + d.set(b"AA".to_vec(), Object::Ref(aa)); + }) + .unwrap(); + let out = full_roundtrip(&extract_pages(&src, &[1, 1]).unwrap()); + let ps = pages(&out).unwrap(); + for i in 0..2 { + let w = annots(&out, i).into_iter().find(|d| d.name(b"Subtype") == Some(b"Widget")).unwrap(); + let action = out.resolve(w.get(b"A").expect("the widget keeps its action")); + let dest = action.as_dict().unwrap().get(b"D").and_then(|a| a.as_array()).unwrap()[0].as_ref().unwrap(); + assert!(ps.iter().any(|p| p.obj == dest), "the action targets a copied page"); + let waa = out.resolve(w.get(b"AA").expect("widget triggers")); + assert!(waa.as_dict().unwrap().contains(b"E")); + assert!(!waa.as_dict().unwrap().contains(b"K")); + let parent = out.get(w.reference(b"Parent").unwrap()).as_dict().unwrap().clone(); + assert!(!parent.contains(b"A")); + let faa = out.resolve(parent.get(b"AA").expect("field triggers")); + assert!(faa.as_dict().unwrap().contains(b"K")); + assert!(!faa.as_dict().unwrap().contains(b"E")); + } +} + #[test] fn repeated_page_gets_independent_annotations() { // A1 has a link, A2 a link and a form field, A3 a note with a popup; each is listed twice. From 21bbae39b9b21a0ed8aab1e92a923b13950a64fc Mon Sep 17 00:00:00 2001 From: Lcoding15 <125505497+Lcoding15@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:41:06 +0200 Subject: [PATCH 33/59] =?UTF-8?q?M3:=20a=20link=20without=20a=20position?= =?UTF-8?q?=20keeps=20the=20reader's=20place=20(ISO=2032000-2=20=C2=A712.3?= =?UTF-8?q?.2.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A /XYZ destination with a null (or unusable) left or top went to the top of the target page. ISO 32000-2 §12.3.2.2 says a null value keeps the current one, and Acrobat Reader, observed on a synthetic two-page fixture at 400 %, keeps the user-space point at the window's top-left on the new page at the same zoom. CAD exports (WSCAD) write every cross-reference as [page /XYZ null null 0], so following one zoomed in landed on the sheet's title row instead of where the reader was looking. go_to_dest now reads the place at the window's top-left before the jump and uses it for the null coordinates, along each axis the page overflows; a page that fits keeps the layout's own placement. Null /FitH top and /FitV left still go to the page edge. Tests: a unit test (zoomed, partly given, view turned a quarter, page that fits) and an end-to-end click on such a link at 400 %, both seen failing with the fix reverted. The malformed-bookmark case in bookmark_destinations_scroll_to_their_position now expects the kept place, as DestView documents unusable operands as null. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 85 +++++++++++++++++++++++++++++++-- crates/ui-egui/tests/it/view.rs | 53 ++++++++++++++++++-- parity/acrobat-features.toml | 4 +- 3 files changed, 131 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1af2a6a4f..2afe4df64 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1126,6 +1126,35 @@ impl DocView { Some((*page, at)) } + /// The user-space `[left, top]` at the window's top-left (where `/XYZ` puts its point), on the + /// page showing there: what a destination's null coordinate keeps (ISO 32000-2 §12.3.2.2). + /// Acrobat keeps the place this way across pages, so a CAD sheet's cross-reference opens the + /// next sheet where the reader was looking. A coordinate is `None` along a screen axis the + /// page fits, which keeps the layout's own placement (centred, or the page's top); the whole + /// place is `None` before the first frame has laid pages out. + fn place_at_corner(&self, info: &DocInfo) -> Option<[Option; 2]> { + let area = self.viewport_screen; + if !area.is_positive() { + return None; + } + let corner = area.min + vec2(SIDE, 0.0); + let (page, xf) = self + .screen_xforms + .iter() + .filter(|(_, xf)| xf.rect.is_positive()) + .min_by(|a, b| a.1.rect.distance_sq_to_pos(corner).total_cmp(&b.1.rect.distance_sq_to_pos(corner)).then(a.0.cmp(&b.0)))?; + let p = info.pages.get(*page)?; + let (x, y) = xf.screen_to_view(corner.clamp(xf.rect.min, xf.rect.max)); + // `max` drops a NaN size, so the bounds stay ordered and `clamp` can't panic. + let (x, y) = (x.clamp(0.0, p.width.max(0.0)), y.clamp(0.0, p.height.max(0.0))); + // The page counts with its side gutters, as the layout places it. + let wide = xf.rect.width() + 2.0 * SIDE > area.width(); + let tall = xf.rect.height() > area.height(); + // The view's x runs across the screen unless the view is turned a quarter. + let (keep_x, keep_y) = if xf.rot.is_multiple_of(180) { (wide, tall) } else { (tall, wide) }; + Some(p.xyz_for_view(keep_x.then_some(x), keep_y.then_some(y))) + } + /// A page's thumbnail texture, when rendered. pub(crate) fn thumb_id(&self, page: usize) -> Option { self.thumbs.get(&page).map(|t| t.tex.id()) @@ -1328,10 +1357,16 @@ impl DocView { /// and zoomed as `dest` asks, as Acrobat does. `/XYZ` puts (left, top) at the window's /// top-left at its zoom (null or 0 keeps the zoom); `/Fit`, `/FitH` and `/FitV` switch to fit /// page, width and height with `top` or `left` at the window's edge; `/FitR` zooms so the - /// rectangle fits, centred. The `B` (content box) forms are read as the page. A null - /// coordinate keeps that scroll position (null `top` on a new page: its top), and a point - /// past the end of the document scrolls as far as it can. + /// rectangle fits, centred. The `B` (content box) forms are read as the page. A null `/XYZ` + /// coordinate keeps the reader's place: the same user-space point stays at the window's + /// top-left on the new page, as Acrobat does. A point past the end of the document scrolls + /// as far as it can. pub fn go_to_dest(&mut self, page: usize, dest: DestView, info: &DocInfo) { + // Read before the jump: it is where the reader is now. + let kept = match dest { + DestView::Xyz { left, top, .. } if left.is_none() || top.is_none() => self.place_at_corner(info), + _ => None, + }; self.go_to_page(page); let page = self.current; self.goto_point = None; @@ -1348,7 +1383,8 @@ impl DocView { self.fit = Fit::None; self.zoom_anchor = None; } - let [fx, fy] = point(left, top); + let [kept_left, kept_top] = kept.unwrap_or([None, None]); + let [fx, fy] = point(left.or(kept_left), top.or(kept_top)); (fx, fy, corner) } DestView::Fit => { @@ -6837,6 +6873,47 @@ trailer << /Root 1 0 R >> assert_eq!(PageLayout::try_parse("facing"), None); } + #[test] + fn a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page() { + // CAD exports link each cross-reference as `/XYZ null null 0`. Acrobat keeps the + // point at the window's top-left (and the zoom) when following one to another sheet. + let info = pdfcraft_render::DocInfo { + pages: (0..3) + .map(|_| pdfcraft_render::PageInfo { width: 842.0, height: 595.0, label: String::new(), crop: [0.0, 0.0, 842.0, 595.0], rotation: 0 }) + .collect(), + ..Default::default() + }; + // Page 1 at 400 %, scrolled so its point 200 pt across and 125 pt down is at the corner + // where /XYZ puts its point. + let zoomed = |rot: u16, size: Vec2| { + let mut v = view(3, PageLayout::Single, true); + v.rotation = rot; + v.viewport_screen = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 600.0)); + v.screen_xforms = vec![(0, PageXform { rect: Rect::from_min_size(pos2(SIDE - 800.0, -500.0), size), rot, pw: 842.0, ph: 595.0 })]; + v + }; + let near = |a: Option, b: f32| a.is_some_and(|a| (a - b).abs() < 1e-4); + let mut v = zoomed(0, vec2(4.0 * 842.0, 4.0 * 595.0)); + v.go_to_dest(1, DestView::Xyz { left: None, top: None, zoom: None }, &info); + let (page, fx, fy, rel) = v.goto_point.expect("the place is kept"); + assert_eq!((v.current, page, rel), (1, 1, vec2(SIDE, 0.0))); + assert!(near(fx, 200.0 / 842.0) && near(fy, 125.0 / 595.0), "{fx:?} {fy:?}"); + // A coordinate the destination gives wins; only the null one is kept. + let mut v = zoomed(0, vec2(4.0 * 842.0, 4.0 * 595.0)); + v.go_to_dest(2, DestView::Xyz { left: Some(421.0), top: None, zoom: Some(4.0) }, &info); + let (_, fx, fy, _) = v.goto_point.expect("the place is kept"); + assert!(near(fx, 0.5) && near(fy, 125.0 / 595.0), "{fx:?} {fy:?}"); + // With the view turned a quarter, the point under the corner is still the one kept. + let mut v = zoomed(90, vec2(4.0 * 595.0, 4.0 * 842.0)); + v.go_to_dest(1, DestView::Xyz { left: None, top: None, zoom: None }, &info); + let (_, fx, fy, _) = v.goto_point.expect("the place is kept"); + assert!(near(fx, 800.0 / (4.0 * 595.0)) && near(fy, 500.0 / (4.0 * 842.0)), "{fx:?} {fy:?}"); + // A page that fits the window keeps the layout's placement: no point, as before. + let mut v = zoomed(0, vec2(800.0, 565.0)); + v.go_to_dest(1, DestView::Xyz { left: None, top: None, zoom: None }, &info); + assert_eq!((v.current, v.goto, v.goto_point), (1, Some((1, 0.0)), None)); + } + #[test] fn destinations_set_the_zoom_mode_and_the_point_to_show() { let info = pdfcraft_render::DocInfo { diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index dc1d5585d..c064bf426 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1137,10 +1137,15 @@ trailer << /Root 1 0 R >> %%EOF"; fn dests_harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + bookmarks_harness("dests.pdf", DESTS) +} + +/// `pdf` open with the Bookmarks panel showing, once the window's layout holds still. +fn bookmarks_harness(name: &'static str, pdf: &'static [u8]) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("dests.pdf", None, DESTS.to_vec()).expect("opens"); + app.open_bytes(name, None, pdf.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("panel", "bookmarks").unwrap(); app @@ -1190,13 +1195,15 @@ fn bookmark_destinations_scroll_to_their_position() { let d = from_viewport_corner(&h, 1, 0.25, 0.5); assert!((d.x - 70.0).abs() < 2.0 && d.y.abs() < 2.0, "the destination point is at the window's top-left: {d:?}"); - // Malformed operands fall back to the top of the page at the current zoom. + // Unusable operands are read as null, which keeps the reader's place in user space: y 300 + // was at the window's top on page 2 (its crop box starts at y 100), so on page 3 (crop from + // 0) y 300 is there too, a quarter down, at the current zoom. h.get_by_label("Broken").click(); h.run_steps(6); let v = &h.state().views[0]; assert_eq!((v.current, v.zoom), (2, 3.0)); - let d = from_viewport_corner(&h, 2, 0.0, 0.0); - assert!(d.y > 0.0 && d.y < 40.0, "page 3 is scrolled to its top: {d:?}"); + let d = from_viewport_corner(&h, 2, 0.0, 0.25); + assert!(d.y.abs() < 2.0, "y 300 on page 3 is at the top of the window: {d:?}"); } #[test] @@ -1219,6 +1226,42 @@ fn link_destinations_scroll_to_their_position() { assert!(d.y.abs() < 2.0, "y 420 on page 2 is at the top of the window: {d:?}"); } +/// Two 300×400 pages. A bookmark shows page 1 at 400% with (40, 360) at the window's top-left; +/// the link there goes to page 2 as CAD exports write their cross-references, `/XYZ null null 0`. +const KEEP_PLACE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [20 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R >> endobj +10 0 obj << /Type /Outlines /First 11 0 R /Last 11 0 R /Count 1 >> endobj +11 0 obj << /Title (Start) /Parent 10 0 R /Dest [3 0 R /XYZ 40 360 4] >> endobj +20 0 obj << /Type /Annot /Subtype /Link /Rect [60 320 140 350] /Border [0 0 0] /Dest [4 0 R /XYZ null null 0] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn a_link_without_a_position_keeps_the_place_on_the_next_page() { + let mut h = bookmarks_harness("keep-place.pdf", KEEP_PLACE); + h.get_by_label("Start").click(); + h.run_steps(6); + assert_eq!((h.state().views[0].current, h.state().views[0].zoom), (0, 4.0)); + let (fx, fy) = (40.0 / 300.0, 40.0 / 400.0); + let d = from_viewport_corner(&h, 0, fx, fy); + assert!((d.x - 70.0).abs() < 2.0 && d.y.abs() < 2.0, "(40, 360) is at the window's top-left: {d:?}"); + + let r = rect(&h, 0).expect("page 1"); + let at = egui::pos2(r.min.x + r.width() * (100.0 / 300.0), r.min.y + r.height() * (1.0 - 335.0 / 400.0)); + h.hover_at(at); + h.run_steps(2); + h.drag_at(at); // press + h.drop_at(at); // release + h.run_steps(8); + let v = &h.state().views[0]; + assert_eq!((v.current, v.zoom), (1, 4.0), "the link goes to page 2 at the same zoom"); + let d = from_viewport_corner(&h, 1, fx, fy); + assert!((d.x - 70.0).abs() < 2.0 && d.y.abs() < 2.0, "page 2 shows the same place, not its top: {d:?}"); +} + /// Two pages with a text field on each and a non-embedded Helvetica. const FORM: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] >> >> endobj diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6d950a71c..2012b6b89 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1178,8 +1178,8 @@ milestone = "M3" status = "shipped" commands = [] tools = ["link_open"] -evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link"] -notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. A Launch action whose /F names a .pdf file (and has no /Win launch parameters) opens that PDF the same way, backslash separators read as / (#4168); URL/network file systems, GoToE, other Launch actions and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit." +evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/ui-egui/tests/it/view.rs::a_link_without_a_position_keeps_the_place_on_the_next_page", "crates/ui-egui/src/canvas.rs::a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link"] +notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. A Launch action whose /F names a .pdf file (and has no /Win launch parameters) opens that PDF the same way, backslash separators read as / (#4168); URL/network file systems, GoToE, other Launch actions and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit. A null /XYZ left or top keeps the reader's place, as ISO 32000-2 §12.3.2.2 says and Acrobat Reader was observed to do (synthetic fixture): the user-space point at the window's top-left stays there on the target page at the same zoom, along each axis the page overflows; CAD exports (WSCAD) write every cross-reference this way. Null /FitH top and /FitV left still go to the page edge." [[feature]] id = "view.annotation-hover" From ba37d412e81482d567452d80c4f56d8996755b0d Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:02:14 -0400 Subject: [PATCH 34/59] test(ui): recovery idle tests make folders that never share a name --- crates/ui-egui/src/recovery.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 17a3410d1..c21e2366b 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -424,9 +424,14 @@ mod idle_tests { struct TestDir(PathBuf); impl TestDir { + /// A new folder of this test's own. The tests run on parallel threads of one process, and + /// two can read the same clock time (it moves in microseconds on macOS), so a counter + /// tells their folders apart; the time keeps runs of the process apart. fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed); let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let path = std::env::temp_dir().join(format!("pdfcraft-idle-recovery-{}-{nonce}", std::process::id())); + let path = std::env::temp_dir().join(format!("pdfcraft-idle-recovery-{}-{nonce}-{n}", std::process::id())); std::fs::create_dir(&path).unwrap(); Self(path) } @@ -441,6 +446,26 @@ mod idle_tests { } } + /// Folders made at the same moment on parallel threads, as the tests make theirs, are each + /// their own: none fails because another test's folder took its name. + #[test] + fn test_folders_made_at_once_on_parallel_threads_are_each_their_own() { + let start = std::sync::Barrier::new(8); + let dirs: Vec = std::thread::scope(|s| { + let threads: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + start.wait(); + (0..25).map(|_| TestDir::new()).collect::>() + }) + }) + .collect(); + threads.into_iter().flat_map(|t| t.join().unwrap()).map(|d| d.path().to_path_buf()).collect() + }); + let distinct: std::collections::HashSet<&PathBuf> = dirs.iter().collect(); + assert_eq!(distinct.len(), 200); + } + fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, now: f64) -> Duration { let mut out = ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| { app.autosave_tick(ui.ctx(), now); From a8839bc5b1a4ecd92d955647ef6b97fd70b00329 Mon Sep 17 00:00:00 2001 From: Kyle Kassen <85040622+KyleKassen@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:58:26 -0500 Subject: [PATCH 35/59] M10: preserve page artwork when printing annotations --- ROADMAP.md | 7 +++- crates/print/README.md | 1 + crates/print/src/lib.rs | 13 ++++-- crates/print/src/tests/appearances.rs | 60 +++++++++++++++++++++++++++ docs/roadmap.md | 5 ++- parity/acrobat-features.toml | 4 +- 6 files changed, 82 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5634d6bf2..c442c0608 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged) > **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) @@ -191,6 +191,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-23 (M10, print resources):** Vector printing preserves original page artwork when existing XObjects use annotation resource names. + The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data. + Estimates unchanged. + - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. @@ -640,6 +644,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | M10: annotation appearances use unoccupied XObject names, preserving original page artwork and distinct appearances through inherited resources and print/reopen. Estimates unchanged. | | 2026-10-22 | minor | M4, #3916 partial: document edits and Undo/Redo cancel Organize drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | | 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. | diff --git a/crates/print/README.md b/crates/print/README.md index ae60e1a2c..1379be2b4 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -35,6 +35,7 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default() Each source page becomes a Form XObject (its content wrapped in q/Q, plus the printable annotations); sheets place them with a clip. The result is a fresh, unencrypted, garbage-collected file (callers check the print permission). +Annotation appearances receive unused XObject names, preserving existing page resources even when they use the same naming prefix. The GUI Print dialog remembers its last selected paper size across application sessions, including when Print is cancelled. The preference stores a canonical name such as `A4`; diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index db65492c1..98d0fd662 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -801,7 +801,7 @@ fn page_form( } let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - let mut k = 0; + let mut k = 0usize; for a in annots { let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; if !prints(doc, &d, content) { @@ -833,8 +833,15 @@ fn page_form( } else { ap }; - let name = format!("PCPrA{k}"); - k += 1; + // Keep the page's own XObjects, including names previous print operations generated. + // Check the growing map so each appearance also keeps the name just assigned to it. + let name = loop { + let name = format!("PCPrA{k}"); + k = k.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation appearance resources".into()))?; + if !xobjects.contains(name.as_bytes()) { + break name; + } + }; let [a0, b0, c0, d0, e0, f0] = m.0; body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); xobjects.set(name.into_bytes(), Object::Ref(ap)); diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs index acf8ece6f..325841987 100644 --- a/crates/print/src/tests/appearances.rs +++ b/crates/print/src/tests/appearances.rs @@ -85,6 +85,66 @@ fn xobject(doc: &Document, owner: &Dict, name: &[u8]) -> ObjRef { objects.as_dict().unwrap().get(name).unwrap().as_ref().unwrap() } +#[test] +fn annotation_appearances_do_not_replace_page_xobjects() { + for inherited in [false, true] { + let (mut doc, ap, widget) = widget_fixture(Some(Object::name("Form")), None); + let page = pdfcraft_model::pages(&doc)[0].obj; + let mut xobjects = Dict::new(); + for (name, color) in [("PCPrA0", "1 0 0"), ("PCPrA1", "0 0 1"), ("PCPrA3", "1 0 0")] { + let dict = Dict::from_iter([ + (b"Type".to_vec(), Object::name("XObject")), + (b"Subtype".to_vec(), Object::name("Form")), + (b"BBox".to_vec(), numbers(&[0.0, 0.0, 10.0, 10.0])), + ]); + let form = doc.add(Object::Stream(Stream::flate(dict, format!("{color} rg 0 0 10 10 re f").as_bytes()))); + xobjects.set(name.as_bytes().to_vec(), Object::Ref(form)); + } + let xobjects = doc.add(Object::Dict(xobjects)); + let resources = doc.add(Object::Dict(Dict::from_iter([(b"XObject".to_vec(), Object::Ref(xobjects))]))); + let contents = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"q 1 0 0 1 60 20 cm /PCPrA0 Do Q\nq 1 0 0 1 70 20 cm /PCPrA1 Do Q\nq 1 0 0 1 80 20 cm /PCPrA3 Do Q", + ))); + let second_ap = doc.add(Object::Stream(Stream::flate(doc.get(ap).as_dict().unwrap().clone(), b"/Ink cs 1 1 0 scn 0 0 20 10 re f"))); + let mut second_widget = doc.get(widget).as_dict().unwrap().clone(); + second_widget.set(b"Rect".to_vec(), numbers(&[10.0, 50.0, 50.0, 70.0])); + second_widget.set(b"AP".to_vec(), Object::Dict(Dict::from_iter([(b"N".to_vec(), Object::Ref(second_ap))]))); + let second_widget = doc.add(Object::Dict(second_widget)); + doc.update_dict(page, |d| { + d.set(b"Resources".to_vec(), Object::Ref(resources)); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(widget), Object::Ref(second_widget)])); + }) + .unwrap(); + if inherited { + let parent = doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(); + doc.update_dict(parent, |d| d.set(b"Resources".to_vec(), Object::Ref(resources))).unwrap(); + doc.update_dict(page, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + } + let before = saved(&doc); + let source = render(before.clone()); + for (x, color) in [(65, [255, 0, 0, 255]), (75, [0, 0, 255, 255]), (85, [255, 0, 0, 255])] { + assert_eq!(pixel(&source, x, 75), color, "source page artwork"); + } + for (y, color) in [(40, [255, 255, 0, 255]), (70, [0, 255, 0, 255])] { + assert_eq!(pixel(&source, 30, y), color, "source widget appearance"); + } + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + for (x, color) in [(65, [255, 0, 0, 255]), (75, [0, 0, 255, 255]), (85, [255, 0, 0, 255])] { + assert_eq!(pixel(&printed, x, 75), color, "printed page artwork, inherited={inherited}"); + } + for (y, color) in [(40, [255, 255, 0, 255]), (70, [0, 255, 0, 255])] { + assert_eq!(pixel(&printed, 30, y), color, "printed widget appearance, inherited={inherited}"); + } + assert_eq!(printed.rgba, source.rgba, "printing must retain page artwork and both appearances, inherited={inherited}"); + assert_eq!(saved(&doc), before, "printing must leave the source resources unchanged"); + } +} + #[test] fn missing_subtype_appearance_survives_vector_print() { let (control, _, _) = widget_fixture(Some(Object::name("Form")), None); diff --git a/docs/roadmap.md b/docs/roadmap.md index bb542aefc..c609f5882 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) @@ -184,7 +184,7 @@ total matches the parity estimate. | 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). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. | | 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). Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). 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). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | 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
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. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | @@ -239,6 +239,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | M10: annotation appearances use unoccupied XObject names, preserving original page artwork and distinct appearances through inherited resources and print/reopen. Estimates unchanged. | | 2026-10-22 | minor | M4, #3916 partial: edits and Undo/Redo cancel Organize page drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | | 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c..41f97c616 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8109,8 +8109,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms"] -notes = "Print flag honoured" +evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects"] +notes = "Print flag honoured; appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources" [[feature]] id = "print.print-with-comment-summary" From 1a69e508261ccb992b1cc34774be073cbea0aced Mon Sep 17 00:00:00 2001 From: Kyle Kassen <85040622+KyleKassen@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:32:46 -0500 Subject: [PATCH 36/59] M10: document annotation resource collision regression --- crates/print/src/tests/appearances.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs index 325841987..0d3107d1d 100644 --- a/crates/print/src/tests/appearances.rs +++ b/crates/print/src/tests/appearances.rs @@ -85,6 +85,8 @@ fn xobject(doc: &Document, owner: &Dict, name: &[u8]) -> ObjRef { objects.as_dict().unwrap().get(name).unwrap().as_ref().unwrap() } +/// Printing keeps occupied page resource names and distinct annotation appearances intact, +/// whether the page resources are direct or inherited from the page tree. #[test] fn annotation_appearances_do_not_replace_page_xobjects() { for inherited in [false, true] { From 3db28cc4e35dba7a8002bddb62e4cd91e2e4cb97 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:15:12 +0800 Subject: [PATCH 37/59] M5: stamps without an appearance draw the stamp they name (ISO 32000-2 Table 184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rubber stamp from another program with no normal appearance was not drawn at all. The display copy now gives it one: - the fourteen standard names of Table 184 draw that stamp with PdfCraft's stamp drawing (eight from the palette, six with labels of their own), fitted to /Rect, in /C or the stamp's colour; - no /Name draws Draft, the table's default, unless /IT says the stamp is an image or a snapshot; - any other name (names match byte for byte, §7.3.5) draws a thin grey frame with both diagonals, so the stamp stays visible and selectable. The drawing is display-only, like the other comments without an appearance: Save writes the file as it was, and build() still declines these stamps, so restyling and flattening never write it into the file. A stamp with its own /AP /N is never touched. stamp() now takes the label and the sign-here flag instead of a StampKind. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/annot/src/appearance.rs | 96 ++++++++++++++++++-- crates/annot/src/lib.rs | 9 +- crates/annot/src/tests.rs | 84 +++++++++++++++++- crates/automation/tests/it/automation.rs | 50 +++++++++++ crates/engine/src/tests.rs | 107 +++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 6 files changed, 339 insertions(+), 11 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 66a86aa30..a8a080e76 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -670,7 +670,8 @@ pub fn build(d: &Dict) -> Option { } let kind = crate::StampKind::from_name(other)?; let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); - return Some(turned(stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); + let sign_here = kind.group() == crate::StampGroup::SignHere; + return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); } } if turn != 0 { @@ -816,9 +817,93 @@ pub fn build(d: &Dict) -> Option { Some(form(rect, &out, res)) } +/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp +/// another program wrote without an appearance. Names are matched byte for byte, as PDF names +/// are compared (§7.3.5). Eight are stamps of PdfCraft's palette; the other six take a label of +/// their own and the palette's colour for their meaning. +fn standard_stamp(name: &[u8]) -> Option<(&'static str, Rgb)> { + use crate::StampKind::*; + let palette = [Approved, NotApproved, NotForPublicRelease, Confidential, Final, ForComment, Draft, ForPublicRelease]; + if let Some(kind) = palette.into_iter().find(|k| k.name().as_bytes() == name) { + return Some((kind.label(), kind.color())); + } + let (label, like) = match name { + b"Experimental" => ("EXPERIMENTAL", Draft), + b"AsIs" => ("AS IS", Draft), + b"Expired" => ("EXPIRED", NotApproved), + b"Sold" => ("SOLD", Approved), + b"Departmental" => ("DEPARTMENTAL", Draft), + b"TopSecret" => ("TOP SECRET", Confidential), + _ => return None, + }; + Some((label, like.color())) +} + +/// The neutral grey of a stamp whose name has no drawing. +const PLACEHOLDER_GREY: Rgb = [0.45, 0.45, 0.45]; + +/// The display appearance of a rubber stamp another program wrote without one: the stamp its +/// `/Name` names (Draft when there is none, Table 184's default), or for any other name a thin +/// grey frame with both diagonals, so the stamp stays visible and can be selected. The name is +/// never drawn as text. +/// +/// For display only ([`crate::with_missing_appearances`]): [`build`] declines these stamps, so +/// edits (restyling, flattening) never write this drawing into the file. `None` for any other +/// annotation, one of PdfCraft's own stamps, a dictionary with an `/AP` (a stamp's own artwork is +/// never replaced; the display copy passes the dictionary without an `/AP` that has no normal +/// appearance), or a `/Rect` that isn't finite or is under a point wide or high. +pub(crate) fn display_stamp(d: &Dict) -> Option { + let own = matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") || d.contains(b"PCOutline"); + if d.name(b"Subtype") != Some(b"Stamp") || own || d.contains(b"AP") { + return None; + } + let rect = nums(d, b"Rect")?; + let [a, b, c, e] = rect.as_slice() else { return None }; + let rect = [a.min(*c), b.min(*e), a.max(*c), b.max(*e)]; + if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); + // A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp. + let stroke = color(d, b"C").flatten(); + let mut res = Dict::new(); + if let Some(gs) = ext_gstate(opacity, false) { + res.set(b"ExtGState".to_vec(), Object::Dict(gs)); + } + // A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default. + let stamp_intent = d.name(b"IT").is_none_or(|it| it == b"Stamp"); + let name = d.name(b"Name").or(stamp_intent.then_some(b"Draft".as_slice())); + if let Some((label, col)) = name.and_then(standard_stamp) { + return Some(stamp(label, false, rect, stroke.unwrap_or(col), None, opacity, res)); + } + let [x0, y0, x1, y1] = rect; + let (a0, b0, a1, b1) = (x0 + 0.5, y0 + 0.5, x1 - 0.5, y1 - 0.5); + let mut c = String::new(); + if opacity < 1.0 { + c.push_str("/GS0 gs\n"); + } + c.push_str(&format!( + "{}1 w\n{} {} {} {} re\n{} {} m {} {} l\n{} {} m {} {} l\nS\n", + rg_stroke(stroke.unwrap_or(PLACEHOLDER_GREY)), + n(a0), + n(b0), + n(a1 - a0), + n(b1 - b0), + n(a0), + n(b0), + n(a1), + n(b1), + n(a0), + n(b1), + n(a1), + n(b0) + )); + Some(form(rect, c.as_bytes(), res)) +} + /// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold /// capitals, and the dynamic stamps' "By … at …" line. -fn stamp(kind: crate::StampKind, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream { +fn stamp(label: &str, sign_here: bool, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream { let [x0, y0, x1, y1] = rect; let h = y1 - y0; let mut c = String::new(); @@ -827,7 +912,7 @@ fn stamp(kind: crate::StampKind, rect: [f64; 4], col: Rgb, by: Option<&str>, opa } let lw = (h * 0.07).clamp(1.0, 3.0); let inset = lw / 2.0 + 0.5; - if kind.group() == crate::StampGroup::SignHere { + if sign_here { // A tag pointing left, filled, with white text. let tip = h * 0.45; c.push_str(&format!( @@ -893,16 +978,15 @@ fn stamp(kind: crate::StampKind, rect: [f64; 4], col: Rgb, by: Option<&str>, opa n(b0) )); } - let label = kind.label(); let (title_h, by_h) = if by.is_some() { (h * 0.5, h * 0.22) } else { (h * 0.52, 0.0) }; - let text_area = if kind.group() == crate::StampGroup::SignHere { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) }; + let text_area = if sign_here { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) }; let mut size = title_h; let tw = text_width(label, size) * 1.12; if tw > text_area.1 - text_area.0 - 4.0 { size *= (text_area.1 - text_area.0 - 4.0) / tw; } let tw = text_width(label, size) * 1.12; - let text_col = if kind.group() == crate::StampGroup::SignHere { [1.0, 1.0, 1.0] } else { col }; + let text_col = if sign_here { [1.0, 1.0, 1.0] } else { col }; let ty = if by.is_some() { y0 + h * 0.48 } else { y0 + (h - size * 0.72) / 2.0 }; let mut out = c.into_bytes(); out.extend( diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 720d39cd8..f506def59 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1586,7 +1586,14 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); continue; } - let Some(stream) = build_appearance(doc, &d) else { continue }; + // Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its + // `/AP`, if any, has no `/N` to keep here. + let other_stamp = || { + let mut bare = d.clone(); + bare.remove(b"AP"); + appearance::display_stamp(&bare) + }; + let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue }; cache.insert(r, &stream); // Fails only when `r` isn't a dictionary, which the build just ruled out. apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 20cc89b10..2af04bc62 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1014,6 +1014,86 @@ fn a_note_without_an_appearance_is_shown_at_icon_size() { assert_eq!(rects(&doc), original, "the source document is unchanged"); } +/// The fourteen rubber stamp names viewers draw without an appearance (ISO 32000-2 Table 184). +const TABLE_184_STAMPS: [(&str, &str); 14] = [ + ("Approved", "APPROVED"), + ("Experimental", "EXPERIMENTAL"), + ("NotApproved", "NOT APPROVED"), + ("AsIs", "AS IS"), + ("Expired", "EXPIRED"), + ("NotForPublicRelease", "NOT FOR PUBLIC RELEASE"), + ("Confidential", "CONFIDENTIAL"), + ("Final", "FINAL"), + ("Sold", "SOLD"), + ("Departmental", "DEPARTMENTAL"), + ("ForComment", "FOR COMMENT"), + ("TopSecret", "TOP SECRET"), + ("Draft", "DRAFT"), + ("ForPublicRelease", "FOR PUBLIC RELEASE"), +]; + +fn stamp_dict(extra: &str) -> Dict { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + format!("<< /Type /Annot /Subtype /Stamp /Rect [20 40 140 100] {extra} >>").as_bytes(), + ]); + list(&doc, 0).remove(0) +} + +fn drawn(d: &Dict) -> String { + String::from_utf8_lossy(&appearance::display_stamp(d).expect("a stamp is drawn").decoded().unwrap()).into_owned() +} + +/// A stamp another program wrote without an appearance draws the stamp its `/Name` names, Draft +/// when it has none, and a frame with both diagonals for any other name, never the name itself. +#[test] +fn a_stamp_without_an_appearance_draws_the_stamp_it_names() { + for (name, label) in TABLE_184_STAMPS { + let content = drawn(&stamp_dict(&format!("/Name /{name}"))); + assert!(content.contains(&format!("({label}) Tj")), "{name}: {content}"); + } + assert!(drawn(&stamp_dict("")).contains("(DRAFT) Tj"), "Table 184's default"); + assert!(drawn(&stamp_dict("/IT /Stamp")).contains("(DRAFT) Tj"), "a stamp intent keeps the default"); + // Any other name, a name in another case (names are compared byte for byte, §7.3.5), and an + // image stamp (`/IT`, which has no `/Name` to default): the frame and its diagonals. + for extra in ["/Name /MyCustomStamp", "/Name /draft", "/Name /APPROVED", "/IT /StampImage", "/Name /PCVoid"] { + let content = drawn(&stamp_dict(extra)); + assert!(!content.contains("Tj") && content.contains(" re\n") && content.matches(" l\n").count() == 2, "{extra}: {content}"); + } + // `/C` colours the stamp, as it does PdfCraft's own. + assert!(drawn(&stamp_dict("/Name /Final /C [1 0 1]")).contains("1 0 1 RG")); + // A stamp's own artwork is never replaced, and the drawing is for display only: edits + // (restyling, flattening) still find nothing they may write. + assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /AP << /N 9 0 R >>")).is_none()); + assert!(appearance::build(&stamp_dict("/Name /Draft")).is_none()); + assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /PCStamp true")).is_none(), "PdfCraft's own stamps are drawn by `build`"); + assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /Rect [5 5 5 50]")).is_none(), "no width"); +} + +/// The display copy draws a stamp that has no normal appearance, and keeps one that has. +#[test] +fn the_display_copy_draws_stamps_without_a_normal_appearance_only() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 40 140 100] /Name /TopSecret >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 140 140 200] /Name /Draft /AP << /N 8 0 R >> >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 240 140 300] /Name /Sold /AP << /D 8 0 R >> >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 340 140 400] /Name /Unheard >>", + b"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 8 >>\nstream\n1 0 0 rg\nendstream", + ]); + let display = with_missing_appearances(&doc).expect("three stamps need an appearance"); + let shown = list(&display, 0); + assert!(ap_content(&display, &shown[0]).contains("(TOP SECRET) Tj")); + assert_eq!(shown[1].get(b"AP"), list(&doc, 0)[1].get(b"AP"), "an existing appearance is kept"); + assert!(ap_content(&display, &shown[2]).contains("(SOLD) Tj"), "an /AP without /N has nothing to keep"); + assert!(!ap_content(&display, &shown[3]).contains("Tj"), "an unknown name gets the placeholder"); + assert!(list(&doc, 0)[0].get(b"AP").is_none(), "the document itself is untouched"); +} + #[test] fn every_line_ending_is_drawn() { let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow]; @@ -1316,9 +1396,9 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() { .expect("strips"); let mut cache = AppearanceCache::default(); let first = with_missing_appearances_cached(&doc, &mut cache).expect("builds"); - assert_eq!((cache.builds(), cache.hits()), (1, 0), "only the note needs an appearance"); + assert_eq!((cache.builds(), cache.hits()), (2, 0), "the note and the file's stamp need an appearance"); let second = with_missing_appearances_cached(&doc, &mut cache).expect("reuses"); - assert_eq!((cache.builds(), cache.hits()), (1, 1), "nothing is redrawn"); + assert_eq!((cache.builds(), cache.hits()), (2, 2), "nothing is redrawn"); let [a, b] = [&first, &second].map(|copy| ap_content(copy, &list(copy, 0)[idx])); assert!(!a.is_empty()); assert_eq!(a, b, "the reused appearance is byte-identical"); diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9fd5c4269..6cd8f3144 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -8424,3 +8424,53 @@ fn bookmark_list_reports_colour_and_style() { let re = ok(&mut b, "doc_open", json!({ "path": "restyled.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved"); } + +/// `page_render`'s pixels show a rubber stamp written without an appearance as the stamp its +/// `/Name` names (ISO 32000-2 Table 184), and one with an unknown name as a grey placeholder. +#[test] +fn page_render_draws_stamps_without_an_appearance() { + let dir = workdir("stamps-without-appearance"); + let objects: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 100] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [10 20 130 80] /Name /Approved >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [170 20 290 80] /Name /MyCustomStamp >>", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + std::fs::write(dir.join("stamps.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "stamps.pdf" }))["doc"].as_u64().unwrap(); + let (width, _, pixels) = a.render_pixels(doc, 1, 1.0).unwrap(); + let pixels = pixels.as_ref(); + // Ink in columns `x0..x1` of rows 20..80, which are the stamps' rows whether counted from the top or the bottom. + let ink = |x0: u32, x1: u32| -> Vec<[u8; 3]> { + (20..80u32) + .flat_map(|y| (x0..x1).map(move |x| (y, x))) + .map(|(y, x)| { + let i = ((y * width + x) * 4) as usize; + [pixels[i], pixels[i + 1], pixels[i + 2]] + }) + .filter(|p| p.iter().any(|v| *v < 200)) + .collect() + }; + let approved = ink(10, 130); + let green = approved.iter().filter(|p| p[1] > p[0] && p[1] > p[2]).count(); + assert!(green > 300 && green * 10 > approved.len() * 9, "Approved is drawn green: {green} of {}", approved.len()); + let custom = ink(170, 290); + let grey = custom.iter().filter(|p| p[0].abs_diff(p[1]) < 24 && p[1].abs_diff(p[2]) < 24).count(); + assert!(grey > 100 && grey == custom.len(), "an unknown name is a grey placeholder: {grey} of {}", custom.len()); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1..3a5d4d500 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5884,6 +5884,113 @@ fn comments_without_appearances_are_drawn_but_not_saved() { } } +/// A stamp's `/Rect` and the colour family expected inside it. +type StampRect = ([f64; 4], char); + +/// Rubber stamps without an appearance (ISO 32000-2 Table 184): the fourteen standard names, one +/// without `/Name`, one custom name, and last a Draft stamp with artwork of its own (magenta). +/// Each sits in a 120 × 60 `/Rect` on a 612 × 792 page; the name and the expected colour family +/// (`'r'`, `'g'`, `'b'`, or `'n'` for the neutral placeholder) of each, in page order. +fn stamps_without_appearances() -> (Arc>, Vec) { + let stamps: [(Option<&str>, char); 16] = [ + (Some("Approved"), 'g'), + (Some("Experimental"), 'b'), + (Some("NotApproved"), 'r'), + (Some("AsIs"), 'b'), + (Some("Expired"), 'r'), + (Some("NotForPublicRelease"), 'r'), + (Some("Confidential"), 'r'), + (Some("Final"), 'g'), + (Some("Sold"), 'g'), + (Some("Departmental"), 'b'), + (Some("ForComment"), 'b'), + (Some("TopSecret"), 'r'), + (Some("Draft"), 'b'), + (Some("ForPublicRelease"), 'b'), + (None, 'b'), + (Some("MyCustomStamp"), 'n'), + ]; + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), String::new()]; + let mut expected = Vec::new(); + for (i, (name, family)) in stamps.iter().enumerate() { + let (x, y) = (30 + (i % 4) * 145, 660 - (i / 4) * 150); + let rect = [x as f64, y as f64, x as f64 + 120.0, y as f64 + 60.0]; + let name = name.map(|n| format!("/Name /{n}")).unwrap_or_default(); + objs.push(format!("<< /Type /Annot /Subtype /Stamp /Rect [{} {} {} {}] {name} /F 4 >>", rect[0], rect[1], rect[2], rect[3])); + expected.push((rect, *family)); + } + let own = objs.len() + 2; + objs.push(format!("<< /Type /Annot /Subtype /Stamp /Rect [30 60 150 120] /Name /Draft /F 4 /AP << /N {own} 0 R >> >>")); + expected.push(([30.0, 60.0, 150.0, 120.0], 'm')); + objs.push("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 23 >>\nstream\n1 0 1 rg 0 0 10 10 re f\nendstream".into()); + let annots: Vec = (4..own).map(|n| format!("{n} 0 R")).collect(); + objs[2] = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", annots.join(" ")); + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + (Arc::new(bytes), expected) +} + +/// The colour family of an RGBA pixel that isn't white: `'r'`, `'g'` or `'b'` for the dominant +/// channel, `'n'` for a grey, `'m'` for magenta. +fn colour_family(p: &[u8]) -> Option { + let (r, g, b) = (i32::from(p[0]), i32::from(p[1]), i32::from(p[2])); + if r.min(g).min(b) > 200 { + return None; + } + Some(if (r - g).abs() < 24 && (g - b).abs() < 24 { + 'n' + } else if r > 200 && b > 200 && g < 80 { + 'm' + } else if r > g && r > b { + 'r' + } else if g > r && g > b { + 'g' + } else { + 'b' + }) +} + +/// Rubber stamps without an appearance are drawn from their `/Name` (ISO 32000-2 Table 184), in +/// the colour of the stamp they name; an unknown name gets a grey placeholder; a stamp's own +/// artwork is drawn as it is. Display only: Save writes the file without new appearances. +#[test] +fn stamps_without_appearances_draw_the_stamp_they_name() { + let (original, expected) = stamps_without_appearances(); + let mut s = Session::new(); + let id = s.open("stamps.pdf", None, original.clone(), None).expect("opens"); + let p = shown(&s, id, 0); + for (i, (rect, family)) in expected.iter().enumerate() { + let mut counts = std::collections::HashMap::new(); + for y in (792.0 - rect[3]) as u32..(792.0 - rect[1]) as u32 { + for x in rect[0] as u32..rect[2] as u32 { + let px = &p.rgba[((y * p.width + x) * 4) as usize..][..4]; + if let Some(f) = colour_family(px) { + *counts.entry(f).or_insert(0) += 1; + } + } + } + let total: u32 = counts.values().sum(); + let main = counts.iter().max_by_key(|(_, n)| **n).map(|(f, _)| *f); + assert!(total > 300, "stamp {i} draws ink inside its /Rect: {counts:?}"); + assert_eq!(main, Some(*family), "stamp {i} is drawn in its colour: {counts:?}"); + } + let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + for n in 4..20 { + assert!(saved.get(pdfcraft_cos::ObjRef::new(n, 0)).as_dict().is_some_and(|d| !d.contains(b"AP")), "{n} is saved without /AP"); + } +} + /// A page with a FreeText comment that has no `/AP` stream: the smallest case behind #2371. fn free_text_without_appearance() -> Arc> { let objs: Vec<&[u8]> = vec![ diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c..4d83fe628 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -806,8 +806,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet" +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet" [[feature]] id = "view.render-error-isolation" From 184e9f30ffa1b67e36f6b417d66a3788a3257098 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:22:46 +0800 Subject: [PATCH 38/59] M10: a booklet decides two-sided itself, as Acrobat does (#2707) dialog hides Two-sided while Booklet is chosen and prints the booklet two-sided itself (Using Acrobat X Pro, Print dialog box options: "Acrobat automatically enables duplex printing, if available, for booklet printing"; later manuals and the current help no longer say so, and on a current Acrobat the Two-sided control is shown for Size and hidden for Booklet with the same duplex-capable printer). print::booklet_duplex gives a booklet's setting: both sides flip on the short edge, the fold of a 2-up sheet turned sideways; front or back only is one pass of a manual duplex and prints one-sided. The Print dialog hides Two-sided while Booklet is chosen and sends that setting, leaving the Two-sided choice as it was for the other layouts (and as #2001 remembers it); doc_print applies the same rule. This replaces the first version, which rewrote Two-sided to the short edge when Booklet was chosen. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/printing.rs | 25 +++++++++++++++- crates/automation/src/tools.rs | 2 +- crates/print/src/lib.rs | 13 +++++++++ crates/print/src/tests.rs | 8 ++++++ crates/ui-egui/src/print_ui.rs | 47 +++++++++++++++++++------------ crates/ui-egui/tests/it/print.rs | 28 ++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 7 files changed, 105 insertions(+), 22 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 39cd45760..6c122feb6 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -22,6 +22,16 @@ fn duplex(value: &str, cut_stack: bool) -> Result> { }) } +/// [`duplex`] for a job laid out as `layout`: the name is checked either way, and a booklet +/// decides two-sided itself (#2707), as the Print dialog does (`print::booklet_duplex`). +fn job_duplex(value: &str, layout: &Layout) -> Result> { + let chosen = duplex(value, matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?; + Ok(match layout { + Layout::Booklet { subset, .. } => Some(print::booklet_duplex(*subset)), + _ => chosen, + }) +} + fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) } @@ -108,7 +118,7 @@ impl Automation { }, l => return Err(bad(format!("unknown layout {l:?}"))), }; - let duplex = duplex(a.opt_str("duplex")?.unwrap_or("off"), matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?; + let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?; if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) { return Err(bad("cut-stack order needs layout multiple")); } @@ -195,6 +205,19 @@ impl Automation { mod tests { use super::*; + /// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error. + #[test] + fn a_booklet_decides_two_sided_itself() { + let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left }; + for asked in ["printer", "off", "long-edge", "short-edge"] { + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BackOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); + } + assert!(job_duplex("sideways", &booklet(BookletSubset::BothSides)).is_err()); + assert_eq!(job_duplex("long-edge", &Layout::Size(SizeMode::Fit)).ok(), Some(Some(spool::Duplex::LongEdge)), "other layouts keep it"); + } + /// `printer` leaves two-sided to the printer's own default (#2422); cut and stack still asks /// for single-sided sheets, and refuses an explicit edge. #[test] diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1e009c117..a4c9b2019 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -543,7 +543,7 @@ pub fn tools() -> Vec { t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", ) .with(schema( json!({ diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index db65492c1..a753768e0 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -149,6 +149,19 @@ pub enum BookletSubset { BackOnly, } +/// How a booklet goes through the printer (#2707): it decides two-sided printing itself, as +/// Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro, +/// Print dialog box options: "Acrobat automatically enables duplex printing, if available, for +/// booklet printing"). Both sides flip on the short edge, the fold of a 2-up sheet turned +/// sideways; front side only or back side only is one pass of a manual duplex and prints +/// single-sided. +pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { + match subset { + BookletSubset::BothSides => spool::Duplex::ShortEdge, + BookletSubset::FrontOnly | BookletSubset::BackOnly => spool::Duplex::Off, + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Binding { #[default] diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 84ab248c7..9ca376cb8 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -961,3 +961,11 @@ fn odd_and_even_pages_are_page_numbers_not_places_in_the_range() { // A range with no page of the kind prints nothing. assert_eq!(select_pages(12, Some("3, 5"), &[], Subset::Even, false), Err(PrintError::NoPages)); } + +/// #2707: a booklet's two-sided setting follows its subset. +#[test] +fn a_booklet_prints_both_sides_on_the_short_edge() { + assert_eq!(booklet_duplex(BookletSubset::BothSides), spool::Duplex::ShortEdge); + assert_eq!(booklet_duplex(BookletSubset::FrontOnly), spool::Duplex::Off); + assert_eq!(booklet_duplex(BookletSubset::BackOnly), spool::Duplex::Off); +} diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index cab6bfc23..ac60fb54c 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -228,8 +228,15 @@ impl PrintDraft { printer: self.printer.clone(), copies: self.copies.max(1), collate: self.collate, - // Cut and stack needs single-sided sheets, whatever the printer's default. - duplex: if self.cut_and_stack() { Some(spool::Duplex::Off) } else { self.duplex }, + // Cut and stack needs single-sided sheets, whatever the printer's default; a booklet + // decides two-sided itself (#2707), leaving the Two-sided choice as it was. + duplex: if self.cut_and_stack() { + Some(spool::Duplex::Off) + } else if self.handling == Handling::Booklet { + Some(print::booklet_duplex(self.booklet_subset)) + } else { + self.duplex + }, scaling: spool::Scaling::Exact, grayscale: self.grayscale, title: title.to_string(), @@ -1017,22 +1024,26 @@ pub(crate) fn body( }); }); ui.end_row(); - ui.label(tl!("Two-sided:")); - ui.add_enabled_ui(!browser, |ui| { - combo( - ui, - "duplex", - &mut d.duplex, - &[ - (None, tl!("Printer default")), - (Some(spool::Duplex::Off), tl!("Off")), - (Some(spool::Duplex::LongEdge), tl!("Flip on long edge")), - (Some(spool::Duplex::ShortEdge), tl!("Flip on short edge")), - ], - 160.0, - ); - }); - ui.end_row(); + // A booklet decides two-sided itself (#2707), so the choice is hidden meanwhile, as + // in Acrobat's Print dialog; it is kept for the other layouts. + if d.handling != Handling::Booklet { + ui.label(tl!("Two-sided:")); + ui.add_enabled_ui(!browser, |ui| { + combo( + ui, + "duplex", + &mut d.duplex, + &[ + (None, tl!("Printer default")), + (Some(spool::Duplex::Off), tl!("Off")), + (Some(spool::Duplex::LongEdge), tl!("Flip on long edge")), + (Some(spool::Duplex::ShortEdge), tl!("Flip on short edge")), + ], + 160.0, + ); + }); + ui.end_row(); + } ui.label(tl!("Paper:")); let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); let before = d.paper; diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index ae325eaaa..21aeca128 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -873,3 +873,31 @@ fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { assert!((size.0 - a4.0).abs() < 0.01 && (size.1 - a4.1).abs() < 0.01, "saved sheet: {size:?}"); std::fs::remove_file(out).unwrap(); } + +/// #2707: a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided +/// while Booklet is chosen): the job flips on the short edge for both sides and prints +/// one-sided for the front or back only, and the Two-sided choice is kept for other layouts. +#[test] +fn a_booklet_decides_two_sided_itself() { + use pdfcraft_engine::print::{BookletSubset, spool::Duplex}; + let mut h = harness(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + h.state_mut().print_draft.duplex = Some(Duplex::LongEdge); + h.run_steps(1); + assert!(h.query_by_label("Two-sided:").is_some()); + h.get_by_label("Booklet").click(); + h.run_steps(2); + assert!(h.query_by_label("Two-sided:").is_none(), "hidden while Booklet is chosen"); + let d = &h.state().print_draft; + assert_eq!(d.duplex, Some(Duplex::LongEdge), "the Two-sided choice is kept"); + assert_eq!(d.job("x").duplex, Some(Duplex::ShortEdge), "both sides flip on the short edge"); + for subset in [BookletSubset::FrontOnly, BookletSubset::BackOnly] { + h.state_mut().print_draft.booklet_subset = subset; + assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::Off), "{subset:?} is one pass of a manual duplex"); + } + h.get_by_label("Size").click(); + h.run_steps(2); + assert!(h.query_by_label("Two-sided:").is_some()); + assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::LongEdge), "back to the choice"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c..a72350e4b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8073,8 +8073,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding"] -notes = "both/front/back, left/right binding" +evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding", "crates/ui-egui/tests/print.rs::a_booklet_decides_two_sided_itself", "crates/automation/src/printing.rs::a_booklet_decides_two_sided_itself", "crates/print/src/tests.rs::a_booklet_prints_both_sides_on_the_short_edge"] +notes = "both/front/back, left/right binding; a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro: Acrobat automatically enables duplex printing for booklet printing): both sides flip on the short edge, front or back only prints one-sided, and the Two-sided choice is kept for the other layouts (#2707)" [[feature]] id = "print.poster" From 2f97fd2d5946624a14f2cb2d140389b24a54ae96 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:27:38 +0800 Subject: [PATCH 39/59] M5: the placeholder for an unknown stamp name stays grey whatever its /C An AP-less stamp whose /Name has no drawing got its placeholder frame in the annotation's /C colour (/C [1 0 0] drew a red frame), while the doc comment and parity note promise a neutral grey one. The frame now always strokes PLACEHOLDER_GREY; the fourteen Table 184 names keep /C. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/annot/src/appearance.rs | 10 ++++++---- crates/annot/src/tests.rs | 3 +++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index a8a080e76..94e77cc53 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -843,9 +843,10 @@ fn standard_stamp(name: &[u8]) -> Option<(&'static str, Rgb)> { const PLACEHOLDER_GREY: Rgb = [0.45, 0.45, 0.45]; /// The display appearance of a rubber stamp another program wrote without one: the stamp its -/// `/Name` names (Draft when there is none, Table 184's default), or for any other name a thin -/// grey frame with both diagonals, so the stamp stays visible and can be selected. The name is -/// never drawn as text. +/// `/Name` names (Draft when there is none, Table 184's default) in its `/C` colour or the +/// stamp's own, or for any other name a thin grey frame with both diagonals, so the stamp stays +/// visible and can be selected. The frame is grey whatever the `/C`: it stands in for a stamp +/// that cannot be drawn, so it takes none of that stamp's colour. The name is never drawn as text. /// /// For display only ([`crate::with_missing_appearances`]): [`build`] declines these stamps, so /// edits (restyling, flattening) never write this drawing into the file. `None` for any other @@ -884,7 +885,8 @@ pub(crate) fn display_stamp(d: &Dict) -> Option { } c.push_str(&format!( "{}1 w\n{} {} {} {} re\n{} {} m {} {} l\n{} {} m {} {} l\nS\n", - rg_stroke(stroke.unwrap_or(PLACEHOLDER_GREY)), + // Grey whatever the `/C`: the placeholder is not the stamp, so it takes none of its colour. + rg_stroke(PLACEHOLDER_GREY), n(a0), n(b0), n(a1 - a0), diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 2af04bc62..ca330bd68 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1064,6 +1064,9 @@ fn a_stamp_without_an_appearance_draws_the_stamp_it_names() { } // `/C` colours the stamp, as it does PdfCraft's own. assert!(drawn(&stamp_dict("/Name /Final /C [1 0 1]")).contains("1 0 1 RG")); + // The placeholder stays grey whatever the `/C`: it is not the stamp, so it takes none of its colour. + let placeholder = drawn(&stamp_dict("/Name /MyCustomStamp /C [1 0 0]")); + assert!(placeholder.contains("0.45 0.45 0.45 RG") && !placeholder.contains("1 0 0 RG"), "{placeholder}"); // A stamp's own artwork is never replaced, and the drawing is for display only: edits // (restyling, flattening) still find nothing they may write. assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /AP << /N 9 0 R >>")).is_none()); From d64a698825e649c37f4a34b86263d10aba6bd379 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:39:26 +0800 Subject: [PATCH 40/59] fix(parity): cite the booklet print test at its path under tests/it (#2707) main moved the ui-egui integration tests into tests/it, so xtask parity could no longer find print.booklet's ui-egui evidence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a72350e4b..4cc2c31dc 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8073,7 +8073,7 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding", "crates/ui-egui/tests/print.rs::a_booklet_decides_two_sided_itself", "crates/automation/src/printing.rs::a_booklet_decides_two_sided_itself", "crates/print/src/tests.rs::a_booklet_prints_both_sides_on_the_short_edge"] +evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding", "crates/ui-egui/tests/it/print.rs::a_booklet_decides_two_sided_itself", "crates/automation/src/printing.rs::a_booklet_decides_two_sided_itself", "crates/print/src/tests.rs::a_booklet_prints_both_sides_on_the_short_edge"] notes = "both/front/back, left/right binding; a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro: Acrobat automatically enables duplex printing for booklet printing): both sides flip on the short edge, front or back only prints one-sided, and the Two-sided choice is kept for the other layouts (#2707)" [[feature]] From 42910de473d5407b7d02337b0e15745e2c47fa0e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:56:46 +0000 Subject: [PATCH 41/59] Reject open-ended page range on a document with no pages Found while testing crates/print/src/range.rs:69-76 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/print/src/range.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/print/src/range.rs b/crates/print/src/range.rs index d03f31a3c..f0d8939da 100644 --- a/crates/print/src/range.rs +++ b/crates/print/src/range.rs @@ -70,7 +70,7 @@ pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset Some(_) if labels.iter().any(|l| l == part) => pages.push(page_of(part, count, labels)?), Some((a, b)) => { let from = if a.trim().is_empty() { 0 } else { page_of(a, count, labels)? }; - let to = if b.trim().is_empty() { count.saturating_sub(1) } else { page_of(b, count, labels)? }; + let to = if b.trim().is_empty() { count.checked_sub(1).ok_or(PrintError::NoPages)? } else { page_of(b, count, labels)? }; if from <= to { pages.extend(from..=to); } else { @@ -115,3 +115,18 @@ fn narrow(pages: Vec, subset: Subset, reverse: bool) -> Result } Ok(out) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_ended_range_of_empty_document_is_an_error() { + assert!(select_pages(0, Some("-"), &[], Subset::All, false).is_err()); + } + + #[test] + fn open_ended_range_covers_all_pages() { + assert_eq!(select_pages(5, Some("-"), &[], Subset::All, false).unwrap(), vec![0, 1, 2, 3, 4]); + } +} From 946fb31eef70cf6bf7fe0b2138e3d263b5bb9944 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:09:05 +0800 Subject: [PATCH 42/59] M3: restore last view settings when reopening documents (#1767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file opened again shows the page, zoom and page display it was last shown with, as Acrobat's "Restore last view settings when reopening documents" does: remembered by path (the last 100 files) when its tab closes or settings are saved with it open, applied over the file's own initial view, on by default and switched off in Preferences ▸ Documents and view. Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 2 + crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/kk.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/js_ui.rs | 3 + crates/ui-egui/src/last_view.rs | 112 ++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 17 +++++ crates/ui-egui/tests/it/ui.rs | 80 ++++++++++++++++++++ parity/acrobat-features.toml | 14 +++- 27 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/last_view.rs diff --git a/ROADMAP.md b/ROADMAP.md index 5634d6bf2..0a5fdd403 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (community, M14, Polish signature panel):** A Polish signature panel listed its details in English: the catalog lacked every signature wording, the changes after signing they name and 34 UI literals. All 101 are translated; `polish_covers_ui_literals` and `polish_says_what_a_signature_says` keep them so. Estimates unchanged. - **2026-10-14 (M14, Finnish localization):** Added Finnish as another interface and date language, with a 2,674-entry catalog covering every key of the other bundled catalogs and two-form plurals (partitive after numbers other than 1). Calendar dates use partitive month names (`14. lokakuuta 2026`). Coverage tests check commands/tools, scanned UI literals and bundled catalog keys. AI-assisted; native proofreading pending. - **2026-10-14 (M14, Catalan localization):** Added Catalan as another interface and date language, with a 2,592-entry clean-room catalog, one/other plurals and coverage tests for commands, tools, UI literals and every key in the other catalogs. A native Catalan speaker (the contributor) has reviewed the translation. Estimates unchanged. + +- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off. - **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-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 765bbba55..603dcd5fb 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2193,6 +2193,7 @@ calendar date October أكتوبر calendar date November نوفمبر calendar date December ديسمبر Reopen the files that were open when PdfCraft last closed إعادة فتح الملفات التي كانت مفتوحة عند آخر إغلاق لـ PdfCraft + Restore the last view (page, zoom and page display) when a document is opened again استعادة آخر عرض (الصفحة والتكبير وعرض الصفحات) عند فتح المستند مرة أخرى Clear Recent Files مسح الملفات الأخيرة Remove from Recent إزالة من الأخيرة Remove {name} from Recent إزالة {name} من الأخيرة diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9bb377a93..da29fa8cd 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2178,6 +2178,7 @@ signature pad Type Въвеждане action wizard Start Старт Margins (in) Полета (in) Reopen the files that were open when PdfCraft last closed Отваряй отново файловете, които бяха отворени при последното затваряне на PdfCraft + Restore the last view (page, zoom and page display) when a document is opened again Възстановяване на последния изглед (страница, мащаб и показване на страниците) при повторно отваряне на документ Clear Recent Files Изчисти последно отваряните файлове Remove from Recent Премахни от последно отваряните Remove {name} from Recent Премахни {name} от последно отваряните diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 66cac29aa..911f8f53e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -139,6 +139,7 @@ Fit one full page Celá jedna stránka Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Používá se, když PDF nepožaduje rozložení ani zvětšení. Plynulé posouvání mezi stránkami neskáče. Reopen the files that were open when PdfCraft last closed Znovu otevřít soubory, které byly otevřené při posledním zavření aplikace PdfCraft + Restore the last view (page, zoom and page display) when a document is opened again Při opětovném otevření dokumentu obnovit poslední zobrazení (stránku, zvětšení a rozložení stránek) Reload documents when their files change on disk Znovu načítat dokumenty při změně jejich souborů na disku Always use my default zoom and page layout Vždy používat mé výchozí zvětšení a rozvržení stránky This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Tento soubor se na disku změnil. Znovu načíst zobrazí novou verzi a zahodí neuložené změny; Uložit ji nahradí touto verzí. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 76366d452..973b2d83d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2186,6 +2186,7 @@ signature pad Type Tippen certificate Subject Zertifikatsinhaber action wizard Start Starten Reopen the files that were open when PdfCraft last closed Beim Start die Dateien wieder öffnen, die beim letzten Beenden von PdfCraft geöffnet waren + Restore the last view (page, zoom and page display) when a document is opened again Beim erneuten Öffnen eines Dokuments die letzte Ansicht (Seite, Zoom und Seitenanzeige) wiederherstellen # Labels that reach tl!() through a variable (Fill & Sign buttons, link prompts, notices, history labels, checker and certificate rows) Add {what} {what} hinzufügen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 18464664a..d7aae3c5b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1467,6 +1467,7 @@ Couldn't import the signature image: {e} Δεν ήταν δυνατή η εισαγωγή της εικόνας υπογραφής: {e} Go to page… Μετάβαση στη σελίδα… Reopen the files that were open when PdfCraft last closed Εκ νέου άνοιγμα των αρχείων που ήταν ανοιχτά κατά τον τελευταίο τερματισμό του PdfCraft + Restore the last view (page, zoom and page display) when a document is opened again Επαναφορά της τελευταίας προβολής (σελίδα, ζουμ και εμφάνιση σελίδων) όταν ένα έγγραφο ανοίγει ξανά The PDF text fonts can't draw “{c}” ({code}). Choose supported text. Οι γραμματοσειρές κειμένου PDF δεν μπορούν να εμφανίσουν τον χαρακτήρα «{c}» ({code}). Επιλέξτε κείμενο που υποστηρίζεται. Open arrow Ανοιχτή αιχμή βέλους Closed arrow Κλειστή αιχμή βέλους diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 73b0ffbf1..51c8922e5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2160,6 +2160,7 @@ action wizard Start Iniciar Lines +/− Líneas +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{bin_added} / −{bin_deleted} recursos binarios, {first} – {last} Reopen the files that were open when PdfCraft last closed Volver a abrir los archivos que estaban abiertos al cerrar PdfCraft por última vez + Restore the last view (page, zoom and page display) when a document is opened again Restaurar la última vista (página, zoom y presentación de páginas) al volver a abrir un documento Add {what} Añadir {what} Use {what} Usar {what} Place saved {what} Colocar {what} desde lo guardado diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ec6e59e56..4969ca1bf 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2041,6 +2041,7 @@ Default zoom Zoom par défaut Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Utilisé lorsqu'un PDF ne demande ni présentation ni zoom. Le défilement continu ne saute jamais d'une page à l'autre. Reopen the files that were open when PdfCraft last closed Rouvrir les fichiers qui étaient ouverts lors de la dernière fermeture de PdfCraft + Restore the last view (page, zoom and page display) when a document is opened again Rétablir la dernière vue (page, zoom et affichage des pages) à la réouverture d’un document Reload documents when their files change on disk Recharger les documents lorsque leurs fichiers sont modifiés sur le disque Always use my default zoom and page layout Toujours utiliser mon zoom et ma présentation de page par défaut This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ce fichier a été modifié sur le disque. Recharger affiche la nouvelle version et abandonne vos modifications non enregistrées ; Enregistrer la remplace par cette version. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f1723629c..21dd3b388 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2170,6 +2170,7 @@ comment menu Edit Szerkesztés signature pad Type Gépelés action wizard Start Indítás Reopen the files that were open when PdfCraft last closed A PdfCraft legutóbbi bezárásakor megnyitott fájlok újranyitása + Restore the last view (page, zoom and page display) when a document is opened again A legutóbbi nézet (oldal, nagyítás és oldalmegjelenítés) visszaállítása a dokumentum újbóli megnyitásakor Clear Recent Files Legutóbbi fájlok listájának törlése Remove from Recent Eltávolítás a legutóbbiak közül Remove {name} from Recent {name} eltávolítása a legutóbbiak közül diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0dc6bb087..6c3de5aeb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2208,6 +2208,7 @@ Remove saved {what} Rimuovi l'elemento salvato: {what} Remove {name} from Recent Rimuovi {name} dai recenti Reopen the files that were open when PdfCraft last closed Riapri i file aperti alla chiusura precedente di PdfCraft + Restore the last view (page, zoom and page display) when a document is opened again Ripristina l’ultima vista (pagina, zoom e visualizzazione delle pagine) quando un documento viene riaperto Reset page size Ripristina dimensione pagine Reset to printer defaults Ripristina le impostazioni predefinite della stampante Reverse closed arrow Freccia chiusa inversa diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 963e8d364..4c4a78389 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2106,6 +2106,7 @@ Go to page… ページへ移動… For page sizes and margins. ページサイズと余白に使います。 Reopen the files that were open when PdfCraft last closed 前回 PdfCraft を閉じたときに開いていたファイルを再度開く + Restore the last view (page, zoom and page display) when a document is opened again 文書を再び開くときに最後の表示(ページ、ズーム、ページ表示)を復元する The PDF text fonts can't draw “{c}” ({code}). Choose supported text. PDF テキスト用フォントでは「{c}」({code})を表示できません。対応している文字を使用してください。 The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 標準フォントでは「{c}」({code})を表示できません。現在、テキストとして追加できるのは西ヨーロッパの文字のみです。 This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. このフィールドのフォントでは「{c}」({code})を表示できないため、PdfCraft では ? と表示されます。値は保存され、他の PDF ビューアーではこの値からフィールドが再描画されます。 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 311626f34..7754cf4d5 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2113,6 +2113,7 @@ calendar date October Қазан calendar date November Қараша calendar date December Желтоқсан Reopen the files that were open when PdfCraft last closed PdfCraft соңғы рет жабылғанда ашық болған файлдарды қайта ашу + Restore the last view (page, zoom and page display) when a document is opened again Құжат қайта ашылғанда соңғы көріністі (бет, масштаб және бет көрсетілімі) қалпына келтіру Clear Recent Files Соңғы файлдарды тазарту Remove from Recent Соңғылардан алып тастау Remove {name} from Recent {name} дегенді соңғылардан алып тастау diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index e2476f196..3d9bc5216 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -987,6 +987,7 @@ Remove {n} pages Pašalinti puslapių: {n} Reopen Closed Tab Vėl atverti užvertą kortelę Reopen the files that were open when PdfCraft last closed Vėl atverti failus, kurie buvo atverti paskutinį kartą užveriant „PdfCraft“ + Restore the last view (page, zoom and page display) when a document is opened again Iš naujo atidarant dokumentą atkurti paskutinį rodinį (puslapį, mastelį ir puslapių rodymą) Repair log Taisymo žurnalas Repeat overlay text Kartoti perdangos tekstą Replace Image… Pakeisti paveikslėlį… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6836f3e0a..fe6088a0e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2085,6 +2085,7 @@ Couldn't import the signature image: {e} Nie można zaimportować obrazu podpisu: {e} Go to page… Przejdź do strony… Reopen the files that were open when PdfCraft last closed Otwórz ponownie pliki otwarte przy ostatnim zamknięciu PdfCraft + Restore the last view (page, zoom and page display) when a document is opened again Przywracaj ostatni widok (stronę, powiększenie i układ stron) przy ponownym otwarciu dokumentu The PDF text fonts can't draw “{c}” ({code}). Choose supported text. Czcionki tekstu PDF nie obsługują znaku „{c}” ({code}). Wybierz obsługiwany tekst. The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Standardowe czcionki nie obsługują znaku „{c}” ({code}). Na razie można dodawać tylko tekst ze znakami zachodnioeuropejskimi. This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Czcionka tego pola nie obsługuje znaku „{c}” ({code}), więc PdfCraft wyświetla w jego miejscu ?. Wartość została zapisana, a inne przeglądarki PDF odrysują pole na jej podstawie. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dcbebae95..6ef9c7e3e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -81,6 +81,7 @@ PdfCraft is drawing without a graphics processor, so it may be slow. Updating the graphics driver may help. O PdfCraft está desenhando sem processador gráfico, por isso pode ficar lento. Atualizar o driver de vídeo pode ajudar. Go to page… Ir para a página… Reopen the files that were open when PdfCraft last closed Reabrir os arquivos que estavam abertos quando o PdfCraft foi fechado pela última vez + Restore the last view (page, zoom and page display) when a document is opened again Restaurar a última visualização (página, zoom e exibição de páginas) ao reabrir um documento Margins Margens For page sizes and margins. Para tamanhos de página e margens. Open Abrir diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0dc40fdba..9fb91165e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2108,6 +2108,7 @@ Default zoom Масштаб по умолчанию Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Используется, когда PDF не запрашивает макет или масштаб. Непрерывная прокрутка никогда не привязывается между страницами. Reopen the files that were open when PdfCraft last closed Повторно открывать файлы, которые были открыты при последнем закрытии PdfCraft + Restore the last view (page, zoom and page display) when a document is opened again Восстанавливать последний вид (страницу, масштаб и показ страниц) при повторном открытии документа Reload documents when their files change on disk Перезагружать документы при изменении их файлов на диске Always use my default zoom and page layout Всегда использовать мой масштаб и макет страницы по умолчанию This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Этот файл был изменён на диске. «Перезагрузить» покажет новую версию и отбросит несохранённые изменения; «Сохранить» заменит её этой версией. diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index f5e374ae7..0cb1636bf 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1506,6 +1506,7 @@ Rename bookmark Preimenuj zaznamek Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL) Izris: hayro (bootstrap) · Vmesnik: egui · Ikone: Lucide (ISC) · Pisave: Inter, JetBrains Mono, Dancing Script (OFL) Reopen the files that were open when PdfCraft last closed Ponovno odpri datoteke, ki so bile odprte ob zadnjem zaprtju PdfCrafta + Restore the last view (page, zoom and page display) when a document is opened again Ob ponovnem odprtju dokumenta obnovi zadnji pogled (stran, povečavo in prikaz strani) Repair log Dnevnik popravil Repairs Popravila Repeat overlay text Ponovi besedilo prekrivanja diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index fa3f6cec4..c53a410fa 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2027,6 +2027,7 @@ action wizard Start ప్రారంభించు Default zoom డిఫాల్ట్ జూమ్ Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF ఏ లేఅవుట్‌ను గానీ జూమ్‌ను గానీ కోరనప్పుడు ఉపయోగించబడుతుంది. నిరంతర స్క్రోలింగ్ పేజీల మధ్య ఎప్పుడూ స్నాప్ అవ్వదు. Reopen the files that were open when PdfCraft last closed PdfCraftను చివరిసారి మూసివేసినప్పుడు తెరిచి ఉన్న ఫైల్‌లను మళ్లీ తెరువు + Restore the last view (page, zoom and page display) when a document is opened again పత్రాన్ని మళ్లీ తెరిచినప్పుడు చివరి వీక్షణను (పేజీ, జూమ్ మరియు పేజీ ప్రదర్శన) పునరుద్ధరించండి Reload documents when their files change on disk డిస్క్‌లో వాటి ఫైల్‌లు మారినప్పుడు పత్రాలను మళ్లీ లోడ్ చేయి Always use my default zoom and page layout ఎల్లప్పుడూ నా డిఫాల్ట్ జూమ్ మరియు పేజీ లేఅవుట్‌ను ఉపయోగించు This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. ఈ ఫైల్ డిస్క్‌లో మారింది. రీలోడ్ కొత్త వెర్షన్‌ను చూపించి సేవ్ చేయని మార్పులను వదిలేస్తుంది; సేవ్ దాని స్థానంలో ఈ వెర్షన్‌ను ఉంచుతుంది. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c292b8863..4285792d2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2139,6 +2139,7 @@ signature pad Type Yaz certificate Subject Konu action wizard Start Başlat Reopen the files that were open when PdfCraft last closed PdfCraft en son kapatıldığında açık olan dosyaları yeniden aç + Restore the last view (page, zoom and page display) when a document is opened again Bir belge yeniden açıldığında son görünümü (sayfa, yakınlaştırma ve sayfa görünümü) geri yükle Add {what} {what} ekle Use {what} {what} kullan Place saved {what} Kayıtlı {what} yerleştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cc8f45765..698e7421b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2191,6 +2191,7 @@ calendar date October жовтня calendar date November листопада calendar date December грудня Reopen the files that were open when PdfCraft last closed Знову відкривати файли, які були відкриті під час останнього закриття PdfCraft + Restore the last view (page, zoom and page display) when a document is opened again Відновлювати останній вигляд (сторінку, масштаб і показ сторінок) під час повторного відкриття документа Clear Recent Files Очистити список нещодавніх файлів Remove from Recent Вилучити з нещодавніх Remove {name} from Recent Вилучити {name} з нещодавніх diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 594f68df8..f8758f31a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2142,6 +2142,7 @@ Used when opening PDFs. An explicit launch or control mode takes precedence for the session. 用于打开 PDF 时。如果启动参数或控制通道明确指定了模式,则本次会话优先使用该模式。 Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF 未指定布局或缩放时使用。连续滚动时页面不会逐页吸附。 Reopen the files that were open when PdfCraft last closed 重新打开上次关闭 PdfCraft 时打开的文件 + Restore the last view (page, zoom and page display) when a document is opened again 再次打开文档时恢复上次的视图(页面、缩放和页面显示) Reload documents when their files change on disk 文件在磁盘上更改时重新加载对应的文档 Always use my default zoom and page layout 始终使用我的默认缩放和页面布局 This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. 此文件已在磁盘上更改。“重新加载”显示新版本并放弃未保存的更改;“保存”则用此版本替换它。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb578ba26..abf697998 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2093,6 +2093,7 @@ Go to page… 轉到頁面… For page sizes and margins. 用於頁面尺寸和邊距。 Reopen the files that were open when PdfCraft last closed 重新開啟上次關閉 PdfCraft 時開啟的檔案 + Restore the last view (page, zoom and page display) when a document is opened again 再次開啟文件時還原上次的檢視(頁面、縮放與頁面顯示) The PDF text fonts can't draw “{c}” ({code}). Choose supported text. PDF 文字字型無法顯示「{c}」({code})。請選擇支援的文字。 The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 標準字型無法顯示「{c}」({code})。目前只能新增西歐字元作為文字。 This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. 此欄位的字型無法顯示「{c}」({code}),PdfCraft 會在該處顯示 ?。值已儲存,其他 PDF 閱讀器會依據該值重新繪製此欄位。 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 7bc3dd044..94f97ca45 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -391,6 +391,9 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok // The web build has no file paths to reopen. #[cfg(not(target_arch = "wasm32"))] ui.add(widgets::checkbox(&mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed"))); + // Acrobat's "Restore last view settings when reopening documents" (#1767). + #[cfg(not(target_arch = "wasm32"))] + ui.add(widgets::checkbox(&mut app.restore_last_view, tl!("Restore the last view (page, zoom and page display) when a document is opened again"))); // The web build has no files on disk to watch. #[cfg(not(target_arch = "wasm32"))] ui.add(widgets::checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk"))); diff --git a/crates/ui-egui/src/last_view.rs b/crates/ui-egui/src/last_view.rs new file mode 100644 index 000000000..ad40ec25e --- /dev/null +++ b/crates/ui-egui/src/last_view.rs @@ -0,0 +1,112 @@ +//! Restore last view settings when reopening documents (Acrobat's Preferences ▸ Documents +//! option; #1767): the page, zoom and page display a file was last shown with, remembered by +//! its path when its tab is closed or settings are saved, and applied when the file is opened +//! again. Desktop only: a document without a file has nothing to be found by. + +use serde::Serialize; + +use crate::PdfCraftApp; +use crate::canvas::{self, ViewDefaults}; + +/// How many files' views are remembered (the most recently closed or saved first). +const MAX_REMEMBERED: usize = 100; + +/// How a file was last shown. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct LastView { + /// Absolute, as `last_session` keys files. + pub path: String, + /// 0-based. + pub page: usize, + /// As settings name it: `fit-width`, `fit-page` or a percentage (`ViewDefaults::zoom_name`). + pub zoom: String, + /// The page display token (`canvas::display_token`). + pub display: String, +} + +/// As `last_session` keys files (and the web has no paths to remember). +#[cfg(not(target_arch = "wasm32"))] +fn absolute(path: &str) -> String { + std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()) +} + +#[cfg(target_arch = "wasm32")] +fn absolute(path: &str) -> String { + path.to_string() +} + +impl PdfCraftApp { + /// How view `index` shows its document now; `None` for a document without a file. + fn view_to_remember(&self, index: usize) -> Option { + let view = self.views.get(index)?; + let path = self.session.get(view.id)?.path.as_deref()?; + let zoom = ViewDefaults { fit: view.fit, zoom: view.zoom, ..ViewDefaults::default() }.zoom_name(); + Some(LastView { path: absolute(path), page: view.current, zoom, display: canvas::display_token(view.layout, view.continuous).to_string() }) + } + + /// Remember how view `index` shows its document (when its tab closes). + pub(crate) fn remember_view(&mut self, index: usize) { + if let Some(v) = self.view_to_remember(index) { + self.last_views.retain(|l| l.path != v.path); + self.last_views.insert(0, v); + self.last_views.truncate(MAX_REMEMBERED); + } + } + + /// The remembered views as settings store them: the open views first, as they are now + /// (so quitting remembers them without closing their tabs), then the rest. + pub(crate) fn last_views_to_save(&self) -> Vec { + let mut out: Vec = (0..self.views.len()).filter_map(|i| self.view_to_remember(i)).collect(); + for l in &self.last_views { + if !out.iter().any(|o| o.path == l.path) { + out.push(l.clone()); + } + } + out.truncate(MAX_REMEMBERED); + out + } + + /// Settings are untrusted: an entry without a usable path, zoom or display is dropped, and + /// the list is capped. + pub(crate) fn restore_last_views(&mut self, v: &serde_json::Value) { + let Some(list) = v.as_array() else { return }; + self.last_views = list + .iter() + .take(MAX_REMEMBERED) + .filter_map(|e| { + let path = e["path"].as_str().filter(|p| !p.is_empty())?.to_string(); + let zoom = e["zoom"].as_str().filter(|z| ViewDefaults::default().with_zoom(z).is_some())?.to_string(); + let display = e["display"].as_str().filter(|d| canvas::parse_display_token(d).is_some())?.to_string(); + let page = usize::try_from(e["page"].as_u64().unwrap_or(0)).unwrap_or(0); + Some(LastView { path, page, zoom, display }) + }) + .collect(); + } + + /// Show view `index` as its document's file was last shown, when the preference is on and + /// the file is remembered. Applied after the document's own initial view, which it + /// overrides: where the reader left off is what they asked to come back to. + pub(crate) fn apply_last_view(&mut self, index: usize) { + if !self.restore_last_view { + return; + } + let Some(view) = self.views.get(index) else { return }; + let Some(doc) = self.session.get(view.id) else { return }; + let Some(path) = doc.path.as_deref().map(absolute) else { return }; + let pages = doc.info.pages.len(); + let Some(last) = self.last_views.iter().find(|l| l.path == path).cloned() else { return }; + let Some(view) = self.views.get_mut(index) else { return }; + if let Some((layout, continuous)) = canvas::parse_display_token(&last.display) { + view.set_display(layout, continuous); + } + if let Some(defaults) = ViewDefaults::default().with_zoom(&last.zoom) { + match defaults.fit { + canvas::Fit::None => view.set_zoom(defaults.zoom), + fit => view.set_fit(fit), + } + } + if last.page < pages { + view.go_to_page(last.page); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index af9912f45..386a778cd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -99,6 +99,7 @@ mod home; mod icon_data; pub mod icons; pub mod last_session; +pub mod last_view; pub mod motion; mod page_selection_ui; mod pageboxes; @@ -584,6 +585,11 @@ pub struct PdfCraftApp { pub recent: Vec, /// Preferences: reopen the files that were open when PdfCraft last closed (#442). pub reopen_last_session: bool, + /// Restore last view settings when reopening documents (Preferences ▸ Documents and view; + /// #1767): the page, zoom and page display a file was last shown with. On by default. + pub restore_last_view: bool, + /// How files were last shown, most recent first (see `last_view`). + pub last_views: Vec, /// The files open when PdfCraft last closed, read from the settings for /// [`PdfCraftApp::reopen_last_files`]. pub last_session: last_session::LastSession, @@ -985,6 +991,8 @@ impl PdfCraftApp { all_tools_expanded: false, recent: Vec::new(), reopen_last_session: false, + restore_last_view: true, + last_views: Vec::new(), last_session: Default::default(), quit_session: None, reload_changed_files: true, @@ -1415,6 +1423,8 @@ impl PdfCraftApp { if activate && initial.full_screen { self.pending_full_screen = Some(id); } + // Where the reader left off wins over the file's own initial view (#1767). + self.apply_last_view(self.views.len() - 1); if activate && self.left_open && self.left == LeftPanel::Tool("organize") @@ -2007,6 +2017,7 @@ impl PdfCraftApp { if index >= self.views.len() { return; } + self.remember_view(index); let id = self.views.remove(index).id; #[cfg(not(target_arch = "wasm32"))] if let Some(path) = self.session.get(id).and_then(|d| d.path.clone()) { @@ -2370,6 +2381,8 @@ impl PdfCraftApp { "docking_right": self.right, "recent": self.recent, "reopen_last_session": self.reopen_last_session, + "restore_last_view": self.restore_last_view, + "last_views": self.last_views_to_save(), // Kept only while the preference is on. "last_session": self.reopen_last_session.then(|| self.session_to_save()), "reload_changed_files": self.reload_changed_files, @@ -2468,6 +2481,10 @@ impl PdfCraftApp { if let Some(on) = v["reopen_last_session"].as_bool() { self.reopen_last_session = on; } + if let Some(on) = v["restore_last_view"].as_bool() { + self.restore_last_view = on; + } + self.restore_last_views(&v["last_views"]); self.last_session = last_session::LastSession::from_json(&v["last_session"]); if let Some(on) = v["system_title_bar"].as_bool() { self.system_title_bar = on; diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 6472b7c66..604eb8178 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -1570,3 +1570,83 @@ fn short_window_rail_buttons_scroll_clear_of_page_navigation() { assert!(tags.bottom() <= page.top(), "Tags {tags:?} scrolls into view above the page field {page:?}"); assert_eq!(h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect(), page, "navigation stays put"); } + +/// A PDF with `n` empty 200×300 pt pages. +fn pages_fixture(n: usize) -> Vec { + let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes()); + for _ in 0..n { + objs.push(b"<< /Type /Page /Parent 2 0 R >>".to_vec()); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A file opened again shows the page, zoom and page display it was last shown with (#1767, +/// Acrobat's "Restore last view settings when reopening documents"): remembered when its tab +/// closes and when settings are saved with it open, applied over the file's own initial view, +/// and not at all with the preference off. Settings are untrusted: bad entries are ignored. +#[test] +fn a_file_opened_again_shows_its_last_view() { + use pdfcraft_ui_egui::canvas::{Fit, PageLayout}; + let path = std::env::temp_dir().join(format!("pdfcraft-last-view-{}.pdf", std::process::id())).to_string_lossy().into_owned(); + let p = path.clone(); + let mut h = harness(move |app| app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens")); + let p = path.clone(); + assert!(h.state().restore_last_view, "on by default"); + { + let app = h.state_mut(); + app.set_option("zoom", "50").unwrap(); + let view = &mut app.views[0]; + view.set_display(PageLayout::TwoUp, false); + view.go_to_page(2); + } + h.run_steps(3); + // Saved with the tab still open: quitting remembers it. + let saved_open = h.state().persist(); + h.state_mut().close_tab(0); + let saved_closed = h.state().persist(); + for (label, saved) in [("open", saved_open), ("closed", saved_closed)] { + let (saved, p) = (saved.clone(), p.clone()); + let h = harness(move |app| { + app.restore(&saved); + app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens"); + }); + let v = &h.state().views[0]; + assert_eq!((v.current, v.fit, v.zoom, v.layout, v.continuous), (2, Fit::None, 0.5, PageLayout::TwoUp, false), "{label}"); + } + // The preference off: the defaults. + let saved = h.state().persist(); + let p = path.clone(); + let h = harness(move |app| { + app.restore(&saved); + app.restore_last_view = false; + app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens"); + }); + let v = &h.state().views[0]; + assert_eq!((v.current, v.fit, v.layout, v.continuous), (0, Fit::Width, PageLayout::Single, true)); + // Garbage in the settings is ignored, a usable entry among it is kept. + let mut app = PdfCraftApp::new(); + app.restore(&format!( + r#"{{"restore_last_view": "yes", "last_views": [5, {{"path": ""}}, {{"path": "{}", "page": -1, "zoom": "huge", "display": "single"}}, {{"path": "{}", "page": 1, "zoom": "fit-page", "display": "two-up"}}]}}"#, + path.replace('\\', "/"), + path.replace('\\', "/") + )); + assert!(app.restore_last_view, "a non-boolean keeps the default"); + assert_eq!(app.last_views.len(), 1); + assert_eq!((app.last_views[0].page, app.last_views[0].zoom.as_str()), (1, "fit-page")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c..f00d5f5d0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -695,7 +695,19 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/editing.rs::the_last_session_reopens_its_files_pages_and_active_tab", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_still_remembers_every_file", "crates/ui-egui/tests/it/editing.rs::the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files", "crates/ui-egui/src/last_session.rs::malformed_sessions_are_skipped_and_capped"] -notes = "Desktop, off by default (Preferences ▸ Documents and view, #442): the files open when PdfCraft last closed reopen at the page each showed, with the same tab in front; files named on the command line open after them, in front; files that are gone, and documents the Recovery dialog offers, are skipped. Quitting with unsaved changes still remembers every tab. Not yet: zoom, layout and scroll position within the page (#418); only the first encrypted file asks for its password (later ones stay closed), and a file opened after it dismisses that prompt; a save that fails partway through a quit can leave the remembered list from that quit; never-saved documents and the web build have no paths to reopen." +notes = "Desktop, off by default (Preferences ▸ Documents and view, #442): the files open when PdfCraft last closed reopen at the page each showed, with the same tab in front; files named on the command line open after them, in front; files that are gone, and documents the Recovery dialog offers, are skipped. Quitting with unsaved changes still remembers every tab. Zoom and page display come back through core.restore-last-view (#1767); not yet the scroll position within the page (#418); only the first encrypted file asks for its password (later ones stay closed), and a file opened after it dismisses that prompt; a save that fails partway through a quit can leave the remembered list from that quit; never-saved documents and the web build have no paths to reopen." + +[[feature]] +id = "core.restore-last-view" +title = "Restore last view settings when reopening documents" +area = "A" +tier = "P2" +milestone = "M3" +status = "shipped" +commands = [] +tools = [] +evidence = ["crates/ui-egui/tests/it/ui.rs::a_file_opened_again_shows_its_last_view"] +notes = "Desktop, on by default (Preferences ▸ Documents and view, #1767): a file opened again shows the page, zoom and page display it was last shown with, remembered by path when its tab closes or when settings are saved with it open (the last 100 files), and applied over the file's own initial view. Not remembered: the view rotation (display only) and the scroll position within the page; Fit height comes back as Fit width (the settings name only two fits)." [[feature]] id = "core.usage-rights-preserve" From aa190d98b27265e2117b94deed3694f9b73a454a Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:09:21 +0800 Subject: [PATCH 43/59] M3: Vietnamese for the restore-last-view preference (#1767) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/vi.tsv | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 87abe5129..e27aab8cd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2780,3 +2780,7 @@ key Space Space a table has 1 to {max} columns một bảng có từ 1 đến {max} cột the first row number can be at most {max} số hàng đầu tiên tối đa là {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 vùng, khoảng cách và độ rộng cột phải là số, khoảng cách từ 0 trở lên và mỗi độ rộng lớn hơn 0 + + +# AI-assisted additions for restoring the last view (#1767) (2026-10-16); not reviewed by a native speaker. + Restore the last view (page, zoom and page display) when a document is opened again Khôi phục chế độ xem gần nhất (trang, thu phóng và hiển thị trang) khi mở lại tài liệu From 5e69e620a32f77f6f959b6a314ba40be545c297b Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:07:36 +0800 Subject: [PATCH 44/59] M12: the last view remembers the two-page cover setting (#1767) Review follow-up on #2195. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/last_view.rs | 20 +++++++++++++++----- crates/ui-egui/tests/it/ui.rs | 5 +++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/last_view.rs b/crates/ui-egui/src/last_view.rs index ad40ec25e..b575d4a39 100644 --- a/crates/ui-egui/src/last_view.rs +++ b/crates/ui-egui/src/last_view.rs @@ -1,7 +1,7 @@ //! Restore last view settings when reopening documents (Acrobat's Preferences ▸ Documents -//! option; #1767): the page, zoom and page display a file was last shown with, remembered by -//! its path when its tab is closed or settings are saved, and applied when the file is opened -//! again. Desktop only: a document without a file has nothing to be found by. +//! option; #1767): the page, zoom and page display (with the two-page cover setting) a file was +//! last shown with, remembered by its path when its tab is closed or settings are saved, and +//! applied when the file is opened again. Desktop only: a document without a file has nothing to be found by. use serde::Serialize; @@ -22,6 +22,8 @@ pub struct LastView { pub zoom: String, /// The page display token (`canvas::display_token`). pub display: String, + /// Whether two-page view showed a cover page (the first page on its own). + pub cover: bool, } /// As `last_session` keys files (and the web has no paths to remember). @@ -41,7 +43,13 @@ impl PdfCraftApp { let view = self.views.get(index)?; let path = self.session.get(view.id)?.path.as_deref()?; let zoom = ViewDefaults { fit: view.fit, zoom: view.zoom, ..ViewDefaults::default() }.zoom_name(); - Some(LastView { path: absolute(path), page: view.current, zoom, display: canvas::display_token(view.layout, view.continuous).to_string() }) + Some(LastView { + path: absolute(path), + page: view.current, + zoom, + display: canvas::display_token(view.layout, view.continuous).to_string(), + cover: view.cover, + }) } /// Remember how view `index` shows its document (when its tab closes). @@ -78,7 +86,8 @@ impl PdfCraftApp { let zoom = e["zoom"].as_str().filter(|z| ViewDefaults::default().with_zoom(z).is_some())?.to_string(); let display = e["display"].as_str().filter(|d| canvas::parse_display_token(d).is_some())?.to_string(); let page = usize::try_from(e["page"].as_u64().unwrap_or(0)).unwrap_or(0); - Some(LastView { path, page, zoom, display }) + let cover = e["cover"].as_bool().unwrap_or(false); + Some(LastView { path, page, zoom, display, cover }) }) .collect(); } @@ -99,6 +108,7 @@ impl PdfCraftApp { if let Some((layout, continuous)) = canvas::parse_display_token(&last.display) { view.set_display(layout, continuous); } + view.set_cover(last.cover); if let Some(defaults) = ViewDefaults::default().with_zoom(&last.zoom) { match defaults.fit { canvas::Fit::None => view.set_zoom(defaults.zoom), diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 604eb8178..03db16ce7 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -1613,6 +1613,7 @@ fn a_file_opened_again_shows_its_last_view() { app.set_option("zoom", "50").unwrap(); let view = &mut app.views[0]; view.set_display(PageLayout::TwoUp, false); + view.set_cover(true); view.go_to_page(2); } h.run_steps(3); @@ -1627,7 +1628,7 @@ fn a_file_opened_again_shows_its_last_view() { app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens"); }); let v = &h.state().views[0]; - assert_eq!((v.current, v.fit, v.zoom, v.layout, v.continuous), (2, Fit::None, 0.5, PageLayout::TwoUp, false), "{label}"); + assert_eq!((v.current, v.fit, v.zoom, v.layout, v.continuous, v.cover), (2, Fit::None, 0.5, PageLayout::TwoUp, false, true), "{label}"); } // The preference off: the defaults. let saved = h.state().persist(); @@ -1638,7 +1639,7 @@ fn a_file_opened_again_shows_its_last_view() { app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens"); }); let v = &h.state().views[0]; - assert_eq!((v.current, v.fit, v.layout, v.continuous), (0, Fit::Width, PageLayout::Single, true)); + assert_eq!((v.current, v.fit, v.layout, v.continuous, v.cover), (0, Fit::Width, PageLayout::Single, true, false)); // Garbage in the settings is ignored, a usable entry among it is kept. let mut app = PdfCraftApp::new(); app.restore(&format!( From 7dc18ee131320c95bda9cdb730fab3ae8f48e33b Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:29:53 +0800 Subject: [PATCH 45/59] M3: the restore-last-view preference in the catalogues added since (#1767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalan, Finnish, Hindi, Indonesian, Korean, Romanian and Albanian have coverage tests the new preference failed after rebasing onto main; Norwegian Bokmål and Thai get it too. AI-assisted, marked as not reviewed by a native speaker. Lao and Central Kurdish fall back to English for now. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ca.tsv | 3 +++ crates/ui-egui/src/i18n/fi.tsv | 3 +++ crates/ui-egui/src/i18n/hi.tsv | 3 +++ crates/ui-egui/src/i18n/id.tsv | 3 +++ crates/ui-egui/src/i18n/ko.tsv | 3 +++ crates/ui-egui/src/i18n/nb.tsv | 3 +++ crates/ui-egui/src/i18n/ro.tsv | 3 +++ crates/ui-egui/src/i18n/sq.tsv | 3 +++ crates/ui-egui/src/i18n/th.tsv | 3 +++ 9 files changed, 27 insertions(+) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index dd2f497b8..b101bc701 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2774,3 +2774,6 @@ theme Appearance Aspecte field names can't contain a period: {name} els noms dels camps no poden contenir cap punt: {name} two cells would both be named {name} dues cel·les es dirien {name} a field named {name} already exists ja existeix un camp anomenat {name} + +# AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. + Restore the last view (page, zoom and page display) when a document is opened again Restaura l'última vista (pàgina, zoom i visualització de pàgina) quan es torna a obrir un document diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index efcf3e3c6..6fc18f9ae 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2783,3 +2783,6 @@ theme Appearance Teema field names can't contain a period: {name} kenttien nimissä ei voi olla pistettä: {name} two cells would both be named {name} kahdella solulla olisi sama nimi {name} a field named {name} already exists kenttä nimeltä {name} on jo olemassa + +# AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. + Restore the last view (page, zoom and page display) when a document is opened again Palauta viimeisin näkymä (sivu, zoomaus ja sivunäyttö), kun asiakirja avataan uudelleen diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7be5ec688..5bc2346fe 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2750,3 +2750,6 @@ theme Appearance स्वरूप field names can't contain a period: {name} फ़ील्ड के नामों में पूर्ण विराम (.) नहीं हो सकता: {name} two cells would both be named {name} दो सेल का नाम {name} हो जाएगा a field named {name} already exists {name} नाम का फ़ील्ड पहले से मौजूद है + +# AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. + Restore the last view (page, zoom and page display) when a document is opened again दस्तावेज़ फिर से खोलने पर पिछला दृश्य (पृष्ठ, ज़ूम और पृष्ठ प्रदर्शन) पुनर्स्थापित करें diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3585b344e..68c97e420 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2751,3 +2751,6 @@ theme Appearance Tampilan field names can't contain a period: {name} nama kolom tidak boleh berisi titik: {name} two cells would both be named {name} dua sel akan sama-sama bernama {name} a field named {name} already exists kolom bernama {name} sudah ada + +# AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. + Restore the last view (page, zoom and page display) when a document is opened again Pulihkan tampilan terakhir (halaman, zoom, dan tampilan halaman) saat dokumen dibuka lagi diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 48ef31e19..d770a5a94 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2750,3 +2750,6 @@ theme Appearance 모양 field names can't contain a period: {name} 필드 이름에는 마침표를 쓸 수 없습니다: {name} two cells would both be named {name} 두 셀의 이름이 모두 {name}이(가) 됩니다 a field named {name} already exists {name}(이)라는 필드가 이미 있습니다 + +# AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. + Restore the last view (page, zoom and page display) when a document is opened again 문서를 다시 열 때 마지막 보기(페이지, 확대/축소, 페이지 표시) 복원 diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 27c1383ec..586bc1c59 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2694,3 +2694,6 @@ key Space Mellomrom # Page gaps (AI-assisted, not separately native-reviewed) Show gaps between pages Vis mellomrom mellom sidene Hide gaps to join page edges. Margins inside the PDF remain unchanged. Skjul mellomrommene slik at sidekantene møtes. Margene inne i PDF-en endres ikke. + +# AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. + Restore the last view (page, zoom and page display) when a document is opened again Gjenopprett den siste visningen (side, zoom og sidevisning) når et dokument åpnes igjen diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 2bd680f91..fb0787746 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2774,3 +2774,6 @@ theme Appearance Aspect field names can't contain a period: {name} numele câmpurilor nu pot conține punct: {name} two cells would both be named {name} două celule s-ar numi amândouă {name} a field named {name} already exists există deja un câmp numit {name} + +# AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. + Restore the last view (page, zoom and page display) when a document is opened again Restabilește ultima vizualizare (pagina, zoomul și afișarea paginilor) când un document este redeschis diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index c93efee5a..cdfbe4a85 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2500,3 +2500,6 @@ animation effects Off Joaktiv field names can't contain a period: {name} emrat e fushave nuk mund të përmbajnë pikë: {name} two cells would both be named {name} dy qeliza do të kishin të dyja emrin {name} a field named {name} already exists ekziston tashmë një fushë me emrin {name} + +# AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. + Restore the last view (page, zoom and page display) when a document is opened again Rikthe pamjen e fundit (faqen, zmadhimin dhe shfaqjen e faqeve) kur një dokument hapet përsëri diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index e56770757..66352933b 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2681,3 +2681,6 @@ key Space Space Page properties คุณสมบัติหน้า The password must have at least 8 characters. รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร theme Appearance ลักษณะที่ปรากฏ + +# AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. + Restore the last view (page, zoom and page display) when a document is opened again คืนค่ามุมมองล่าสุด (หน้า การซูม และการแสดงหน้า) เมื่อเปิดเอกสารอีกครั้ง From fb53ddf02f739b1a0b6e648b8c0a9486b32d2158 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:04:06 +0800 Subject: [PATCH 46/59] M3: full screen neither is remembered as the last view nor hides it (#1767) CodeRabbit on #2195: a file opened while full screen was on entered the presentation first, and the remembered view then replaced its one fitted page, while leaving full screen went back to the display from before the restore; and a tab closed (or settings saved) in full screen remembered full screen's temporary fit and display. The remembered view is now applied before the presentation starts, and the view remembered is the normal display (DocView::normal_display: the one presenting replaced) at the page reached. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 11 ++++++++++ crates/ui-egui/src/last_view.rs | 10 +++++---- crates/ui-egui/src/lib.rs | 6 +++--- crates/ui-egui/tests/it/ui.rs | 37 +++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1af2a6a4f..38913a943 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1514,6 +1514,17 @@ impl DocView { self.goto = Some((self.current, 0.0)); } + /// The display shown outside full screen: the one presenting replaced, or the current one. + pub fn normal_display(&self) -> SavedDisplay { + self.presentation.unwrap_or(SavedDisplay { + layout: self.layout, + continuous: self.continuous, + cover: self.cover, + fit: self.fit, + zoom: self.zoom, + }) + } + /// Whether the view is in full screen's one-page-at-a-time display. pub fn presenting(&self) -> bool { self.presentation.is_some() diff --git a/crates/ui-egui/src/last_view.rs b/crates/ui-egui/src/last_view.rs index b575d4a39..691be7107 100644 --- a/crates/ui-egui/src/last_view.rs +++ b/crates/ui-egui/src/last_view.rs @@ -38,17 +38,19 @@ fn absolute(path: &str) -> String { } impl PdfCraftApp { - /// How view `index` shows its document now; `None` for a document without a file. + /// How view `index` shows its document now; `None` for a document without a file. In full + /// screen, the display it will go back to, at the page reached. fn view_to_remember(&self, index: usize) -> Option { let view = self.views.get(index)?; let path = self.session.get(view.id)?.path.as_deref()?; - let zoom = ViewDefaults { fit: view.fit, zoom: view.zoom, ..ViewDefaults::default() }.zoom_name(); + let shown = view.normal_display(); + let zoom = ViewDefaults { fit: shown.fit, zoom: shown.zoom, ..ViewDefaults::default() }.zoom_name(); Some(LastView { path: absolute(path), page: view.current, zoom, - display: canvas::display_token(view.layout, view.continuous).to_string(), - cover: view.cover, + display: canvas::display_token(shown.layout, shown.continuous).to_string(), + cover: shown.cover, }) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 386a778cd..33375c5d9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1412,7 +1412,9 @@ impl PdfCraftApp { self.active = Some(self.views.len() - 1); } self.apply_initial_view(self.views.len() - 1, &initial); - // After the document's own initial view, so leaving full screen comes back to it. + // Where the reader left off wins over the file's own initial view (#1767). + self.apply_last_view(self.views.len() - 1); + // After both, so full screen shows one fitted page and leaving it comes back to them. if self.full_screen && let Some(view) = self.views.last_mut() { @@ -1423,8 +1425,6 @@ impl PdfCraftApp { if activate && initial.full_screen { self.pending_full_screen = Some(id); } - // Where the reader left off wins over the file's own initial view (#1767). - self.apply_last_view(self.views.len() - 1); if activate && self.left_open && self.left == LeftPanel::Tool("organize") diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 03db16ce7..5675de66c 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -1651,3 +1651,40 @@ fn a_file_opened_again_shows_its_last_view() { assert_eq!(app.last_views.len(), 1); assert_eq!((app.last_views[0].page, app.last_views[0].zoom.as_str()), (1, "fit-page")); } + +/// #1767 with full screen: the view remembered while presenting is the one full screen will go +/// back to, and a file reopened in full screen presents one fitted page, then leaves full +/// screen for the remembered view. +#[test] +fn full_screen_neither_is_remembered_nor_hides_the_last_view() { + use pdfcraft_ui_egui::canvas::{Fit, PageLayout}; + let path = std::env::temp_dir().join(format!("pdfcraft-last-view-fs-{}.pdf", std::process::id())).to_string_lossy().into_owned(); + let p = path.clone(); + let mut h = harness(move |app| app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens")); + { + let app = h.state_mut(); + app.set_option("zoom", "50").unwrap(); + let view = &mut app.views[0]; + view.set_display(PageLayout::TwoUp, false); + view.go_to_page(2); + view.enter_presentation(); + assert!(view.presenting() && view.fit == Fit::Page); + } + h.run_steps(3); + let saved = h.state().persist(); + h.state_mut().close_tab(0); + let remembered = h.state().last_views[0].clone(); + assert_eq!((remembered.page, remembered.zoom.as_str()), (2, "50%"), "not full screen's fitted page"); + let p = path.clone(); + let mut h = harness(move |app| { + app.restore(&saved); + app.full_screen = true; + app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens"); + }); + let v = &h.state().views[0]; + assert!(v.presenting(), "opened in full screen"); + assert_eq!((v.fit, v.continuous, v.current), (Fit::Page, false, 2), "one fitted page, where the reader left off"); + let view = &mut h.state_mut().views[0]; + view.leave_presentation(); + assert_eq!((view.fit, view.zoom, view.layout, view.continuous), (Fit::None, 0.5, PageLayout::TwoUp, false), "the remembered view"); +} From 6506c8cc45e9905e2023cd3c09f721bf70e6df43 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:33:38 +0800 Subject: [PATCH 47/59] M3: a fractional zoom is remembered as it was, not rounded to a whole percent (#1767) Review follow-up on #2195: the last view stored ViewDefaults::zoom_name(), which rounds 62.5% to 63%. A fixed zoom is now written with its fraction (with_zoom already reads it back); the fit tokens are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/ui-egui/src/last_view.rs | 9 +++++++-- crates/ui-egui/tests/it/ui.rs | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/last_view.rs b/crates/ui-egui/src/last_view.rs index 691be7107..13012b1d6 100644 --- a/crates/ui-egui/src/last_view.rs +++ b/crates/ui-egui/src/last_view.rs @@ -18,7 +18,8 @@ pub struct LastView { pub path: String, /// 0-based. pub page: usize, - /// As settings name it: `fit-width`, `fit-page` or a percentage (`ViewDefaults::zoom_name`). + /// As settings name it: `fit-width`, `fit-page` or a percentage, kept to its fraction + /// (`62.5%`; read back by `ViewDefaults::with_zoom`). pub zoom: String, /// The page display token (`canvas::display_token`). pub display: String, @@ -44,7 +45,11 @@ impl PdfCraftApp { let view = self.views.get(index)?; let path = self.session.get(view.id)?.path.as_deref()?; let shown = view.normal_display(); - let zoom = ViewDefaults { fit: shown.fit, zoom: shown.zoom, ..ViewDefaults::default() }.zoom_name(); + // A fixed zoom keeps its fraction (62.5%, not 63%): `zoom_name` rounds to whole percents. + let zoom = match shown.fit { + canvas::Fit::None => format!("{}%", shown.zoom * 100.0), + fit => ViewDefaults { fit, zoom: shown.zoom, ..ViewDefaults::default() }.zoom_name(), + }; Some(LastView { path: absolute(path), page: view.current, diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 5675de66c..a83d882a6 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -1652,6 +1652,26 @@ fn a_file_opened_again_shows_its_last_view() { assert_eq!((app.last_views[0].page, app.last_views[0].zoom.as_str()), (1, "fit-page")); } +/// #1767: a fixed zoom with a fraction comes back as it was, not rounded to a whole percent. +#[test] +fn a_fractional_zoom_is_remembered_exactly() { + use pdfcraft_ui_egui::canvas::Fit; + let path = std::env::temp_dir().join(format!("pdfcraft-last-view-frac-{}.pdf", std::process::id())).to_string_lossy().into_owned(); + let p = path.clone(); + let mut h = harness(move |app| app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens")); + h.state_mut().views[0].set_zoom(0.625); + h.run_steps(3); + h.state_mut().close_tab(0); + assert_eq!(h.state().last_views[0].zoom, "62.5%"); + let saved = h.state().persist(); + let h = harness(move |app| { + app.restore(&saved); + app.open_bytes("doc.pdf", Some(path), pages_fixture(3)).expect("opens"); + }); + let v = &h.state().views[0]; + assert_eq!((v.fit, v.zoom), (Fit::None, 0.625)); +} + /// #1767 with full screen: the view remembered while presenting is the one full screen will go /// back to, and a file reopened in full screen presents one fitted page, then leaves full /// screen for the remembered view. From 001a900ac71a46dbcb5680f5c3ea5720519d2747 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:41:46 +0800 Subject: [PATCH 48/59] =?UTF-8?q?M5:=20comments=20without=20an=20appearanc?= =?UTF-8?q?e=20draw=20their=20/Border=20dashes,=20Ink=20dashes=20and=20the?= =?UTF-8?q?=20caret's=20=C2=B6=20(ISO=2032000-2=20Tables=20166,=20168,=201?= =?UTF-8?q?83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments that arrive without /AP are drawn from their dictionaries by appearance::build, which lost three entries: - /Border [hr vr w [dash]] (Table 166): the dash array made the whole array unreadable, so the width fell back to 1 and the border was solid. The width is now element 2 whatever follows, and without /BS the /Border dash array is drawn. /BS, when present, wins for both (Table 166: "the Border entry is ignored"), so a /BS without /W means 1. - Dash arrays are drawn only when finite, non-negative, not all zero (§8.4.3.6) and at most 64 long; anything else draws solid. - Ink applies its /BS dash pattern (Table 168, Table 182), like the other stroked shapes. - A caret with /Sy /P (Table 183) shows a ¶ (Helvetica, WinAnsi 0xB6) to its right in its colour, inside /Rect: in the room /RD leaves, or by narrowing the caret. /Sy /None draws as before. The Helvetica font resource, written out three times, is one helper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/annot/src/appearance.rs | 128 +++++++++++++++++++++++++-------- crates/annot/src/tests.rs | 109 ++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 50 +++++++++++++ parity/acrobat-features.toml | 4 +- 4 files changed, 258 insertions(+), 33 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 66a86aa30..0e0f3a510 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -180,23 +180,42 @@ fn interior_color(d: &Dict, fallback: Option) -> Option { color(d, b"IC").flatten().or(fallback) } +/// The border width (ISO 32000-2 §12.5.4): `/BS /W` when there is a border style dictionary, which +/// makes `/Border` ignored (Table 166), else the third element of `/Border [hr vr w [dash]]`, read +/// whether or not the optional fourth element, a dash array, follows. 1 when neither says, and +/// when the number given is negative or not finite. pub(crate) fn border_width(d: &Dict) -> f64 { - if let Some(w) = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()) { - return w.max(0.0); - } - match nums(d, b"Border") { - Some(b) if b.len() >= 3 => b[2].max(0.0), - _ => 1.0, - } + let width = match d.get(b"BS").and_then(Object::as_dict) { + Some(bs) => bs.get(b"W").and_then(Object::as_f64), + None => d.get(b"Border").and_then(Object::as_array).and_then(|b| b.get(2)).and_then(Object::as_f64), + }; + width.filter(|w| w.is_finite() && *w >= 0.0).unwrap_or(1.0) } -/// Dash pattern operator for a dashed border, if any. +/// Elements a dash array may have before it is drawn solid instead: the lengths are written into +/// the appearance as they are, so a file can't make it arbitrarily large. +const MAX_DASH: usize = 64; + +/// A dash array (§8.4.3.6) as drawn: every element finite and non-negative, not all zero (that +/// pattern is invalid) and at most [`MAX_DASH`] long. `None` for anything else, which is drawn solid. +fn dash_array(o: &Object) -> Option> { + let a = o.as_array().filter(|a| !a.is_empty() && a.len() <= MAX_DASH)?; + let v: Vec = a.iter().map(Object::as_f64).collect::>()?; + (v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0)).then_some(v) +} + +/// Dash pattern operator for a dashed border, if any: `/BS /S /D` with its `/D` (default `[3]`, +/// Table 168), or, without `/BS`, the dash array that may follow the width in `/Border` (Table 166). fn dash(d: &Dict) -> String { - let Some(bs) = d.get(b"BS").and_then(|b| b.as_dict()) else { return String::new() }; - if bs.name(b"S") != Some(b"D") { - return String::new(); - } - let arr = nums(bs, b"D").filter(|a| !a.is_empty() && a.iter().all(|x| *x >= 0.0)).unwrap_or_else(|| vec![3.0]); + let arr = match d.get(b"BS").and_then(Object::as_dict) { + Some(bs) if bs.name(b"S") != Some(b"D") => return String::new(), + Some(bs) => match bs.get(b"D") { + None => Some(vec![3.0]), + Some(o) => dash_array(o), + }, + None => d.get(b"Border").and_then(Object::as_array).and_then(|b| b.get(3)).and_then(dash_array), + }; + let Some(arr) = arr else { return String::new() }; format!("[{}] 0 d\n", arr.iter().map(|x| n(*x)).collect::>().join(" ")) } @@ -299,6 +318,55 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { Some((what, first_undrawable(&text)?)) } +/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding. +fn helvetica_resource() -> Object { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + let mut fonts = Dict::new(); + fonts.set(b"Helv".to_vec(), Object::Dict(font)); + Object::Dict(fonts) +} + +/// Where a caret and its paragraph symbol go within `rect`: the box the caret is drawn in, and for +/// `/Sy /P` the symbol's baseline origin and font size (ISO 32000-2 Table 183). +/// +/// The caret lies within `/Rect` less `/RD` (left, top, right, bottom), when `/RD` is four finite, +/// non-negative numbers that leave a box; otherwise within `/Rect`. The ¶ sits to the right of the +/// caret, its height a little over half the caret's, in the room `/RD` leaves on the right; when +/// that is too narrow, the caret gives up to half its width and the symbol shrinks to what is left, +/// so both stay inside `/Rect` (the form's `/BBox`). +fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>) { + let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| x.is_finite() && *x >= 0.0)); + let mut caret = match rd { + Some(rd) => [rect[0] + rd[0], rect[1] + rd[3], rect[2] - rd[2], rect[3] - rd[1]], + None => rect, + }; + if !(caret[2] > caret[0] && caret[3] > caret[1]) { + caret = rect; + } + if d.name(b"Sy") != Some(b"P") { + return (caret, None); + } + let size = (caret[3] - caret[1]) * 0.6; + let (gap, glyph) = (size * 0.1, text_width("\u{b6}", size)); + let need = gap + glyph; + let room = rect[2] - caret[2]; + if room < need { + caret[2] -= (need - room).min((caret[2] - caret[0]) * 0.5); + } + let room = rect[2] - caret[2]; + let scale = if need > room { room / need } else { 1.0 }; + let size = size * scale; + if !(size.is_finite() && size > 0.0) { + return (caret, None); + } + // The pilcrow descends about a sixth of the size below its baseline. + (caret, Some((caret[2] + gap * scale, caret[1] + size * 0.18, size))) +} + /// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported. /// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and /// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`). @@ -512,7 +580,8 @@ pub fn build(d: &Dict) -> Option { b"Caret" => { // A filled caret: two curved flanks meeting at the top centre. let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let [x0, y0, x1, y1] = rect; + let (caret, pilcrow) = caret_layout(d, rect); + let [x0, y0, x1, y1] = caret; let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); c.push_str(&format!( "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", @@ -532,11 +601,22 @@ pub fn build(d: &Dict) -> Option { n(x1), n(y0) )); + if let Some((x, y, size)) = pilcrow { + // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. + // 0xB6 is the pilcrow in WinAnsiEncoding. + let mut out = c.into_bytes(); + out.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); + out.push(0xB6); + out.extend_from_slice(b") Tj ET\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + return Some(form(rect, &out, res)); + } } b"Ink" => { let col = stroke?; let list = d.get(b"InkList")?.as_array()?; - c.push_str(&format!("{}{} w 1 J 1 j\n", rg_stroke(col), n(w))); + // `/BS` gives an ink stroke its width and dash pattern (Table 182). + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); for s in list { let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); @@ -773,14 +853,7 @@ pub fn build(d: &Dict) -> Option { y -= size * 1.2; } out.extend_from_slice(b"ET\n"); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let mut fonts = Dict::new(); - fonts.set(b"Helv".to_vec(), Object::Dict(font)); - res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(b"Font".to_vec(), helvetica_resource()); let mut stream = form(full, &out, res); if let Some(m) = matrix { stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); @@ -803,14 +876,7 @@ pub fn build(d: &Dict) -> Option { // WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8. out.extend(literal(&win_ansi(&value))); out.extend_from_slice(b" Tj ET\n"); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let mut fonts = Dict::new(); - fonts.set(b"Helv".to_vec(), Object::Dict(font)); - res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(b"Font".to_vec(), helvetica_resource()); } } Some(form(rect, &out, res)) diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 20cc89b10..c94be35d0 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1675,3 +1675,112 @@ fn a_star_note_keeps_its_star_when_recoloured() { let Object::Stream(s) = &*doc.get(n) else { panic!() }; assert_eq!(s.decoded().unwrap(), note_drawing(Some("Star"), [0.0, 0.0, 1.0])); } + +/// The built appearance's content for an annotation dictionary written in PDF syntax. +fn content_of(src: &str) -> String { + let doc = document_of(&[src.as_bytes()]); + let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("a dictionary"); + let stream = appearance::build(&d).expect("drawn"); + String::from_utf8_lossy(&stream.decoded().unwrap()).into_owned() +} + +/// ISO 32000-2 Table 166: `/Border [hr vr w [dash]]`; the optional fourth element, a dash array, +/// used to hide the width (drawn 1 pt solid instead of 3 pt dashed). +#[test] +fn legacy_border_width_and_dash_are_drawn() { + let square = |border: &str| content_of(&format!("<< /Subtype /Square /Rect [0 0 100 100] /C [1 0 0] {border} >>")); + let c = square("/Border [0 0 3 [6 3]]"); + assert!(c.contains("3 w") && c.contains("[6 3] 0 d"), "{c}"); + assert!(c.contains("1.5 1.5 97 97 re"), "the stroke is inset by half its width: {c}"); + let c = square("/Border [0 0 3]"); + assert!(c.contains("3 w") && !c.contains(" d\n"), "no dash array: solid: {c}"); + // `/BS` overrides `/Border` (Table 166), for the width and the dashes alike. + let c = square("/Border [0 0 3 [6 3]] /BS << /W 2 >>"); + assert!(c.contains("2 w") && !c.contains(" d\n"), "{c}"); + let c = square("/Border [0 0 3 [6 3]] /BS << /W 2 /S /D /D [4 1] >>"); + assert!(c.contains("2 w") && c.contains("[4 1] 0 d"), "{c}"); + // A /BS without /W means its default width 1, not the ignored /Border's. + let c = square("/Border [0 0 3] /BS << /S /S >>"); + assert!(c.contains("1 w") && !c.contains("3 w"), "{c}"); + // A FreeText border and a line take the legacy dash array too. + let c = content_of("<< /Subtype /FreeText /Rect [0 0 100 40] /Contents (x) /Border [0 0 2 [5 2]] >>"); + assert!(c.contains("2 w") && c.contains("[5 2] 0 d"), "{c}"); + let c = content_of("<< /Subtype /Line /Rect [0 0 100 100] /L [10 10 90 90] /C [0 0 1] /Border [0 0 2 [5 2]] >>"); + assert!(c.contains("[5 2] 0 d"), "{c}"); +} + +/// Dash arrays that can't be drawn (§8.4.3.6: all zeros is invalid; negative or non-numeric +/// lengths, and absurdly long arrays) draw solid, without a panic. A non-finite or negative +/// width falls back to 1. +#[test] +fn bad_dash_arrays_draw_solid() { + let square = |border: &str| content_of(&format!("<< /Subtype /Square /Rect [0 0 100 100] /C [1 0 0] {border} >>")); + let long = format!("[{}]", vec!["1"; 65].join(" ")); + for bad in ["[0 0]", "[-3 2]", "[]", "[(a) 2]", "5", long.as_str()] { + let c = square(&format!("/Border [0 0 3 {bad}]")); + assert!(c.contains("3 w") && !c.contains(" d\n"), "{bad}: {c}"); + let c = square(&format!("/BS << /W 3 /S /D /D {bad} >>")); + assert!(c.contains("3 w") && !c.contains(" d\n"), "{bad}: {c}"); + } + assert!(square(&format!("/Border [0 0 3 [{}]]", vec!["1"; 64].join(" "))).contains(" 0 d\n"), "64 elements are drawn"); + // `/S /D` without `/D` uses the default `[3]` (Table 168). + assert!(square("/BS << /W 3 /S /D >>").contains("[3] 0 d")); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Square")); + d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 100.0, 100.0].map(Object::Real).to_vec())); + d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].map(Object::Real).to_vec())); + for w in [f64::NAN, f64::INFINITY, -2.0] { + let dash = Object::Array([f64::NAN, 2.0].map(Object::Real).to_vec()); + d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Real(w), dash])); + let c = String::from_utf8_lossy(&appearance::build(&d).unwrap().decoded().unwrap()).into_owned(); + assert!(c.contains("1 w") && !c.contains(" d\n") && !c.contains("NaN") && !c.contains("inf"), "{w}: {c}"); + } +} + +/// Table 182: an ink annotation's `/BS` gives the width and dash pattern its paths are drawn with. +#[test] +fn ink_draws_its_border_style_dashes() { + let ink = |style: &str| content_of(&format!("<< /Subtype /Ink /Rect [0 0 100 100] /C [1 0 0] /InkList [[5 5 50 95 95 5]] {style} >>")); + let c = ink("/BS << /W 3 /S /D /D [6 3] >>"); + assert!(c.contains("3 w 1 J 1 j\n[6 3] 0 d"), "{c}"); + assert!(!ink("/BS << /W 3 >>").contains(" d\n")); + assert!(ink("/Border [0 0 2 [4 4]]").contains("[4 4] 0 d")); +} + +/// Table 183: `/Sy /P` associates a new paragraph symbol (¶) with the caret. It is drawn to the +/// caret's right in the caret's colour, inside `/Rect`; `/Sy /None` (the default) draws none. +#[test] +fn a_caret_with_a_paragraph_symbol_draws_the_pilcrow() { + let caret = |extra: &str| { + let doc = document_of(&[format!("<< /Subtype /Caret /Rect [30 30 60 60] /C [0.9 0.1 0.1] {extra} >>").as_bytes()]); + let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap(); + let s = appearance::build(&d).unwrap(); + (s.decoded().unwrap(), s.dict.get(b"Resources").and_then(Object::as_dict).is_some_and(|r| r.contains(b"Font"))) + }; + let has_pilcrow = |c: &[u8]| c.windows(5).any(|w| w == b"(\xb6) T"); + let (c, font) = caret("/Sy /P"); + assert!(has_pilcrow(&c) && font, "{}", String::from_utf8_lossy(&c)); + let text = String::from_utf8_lossy(&c).into_owned(); + assert!(text.starts_with("0.9 0.1 0.1 rg"), "{text}"); + // The caret gives up room on the right for the symbol, which stays within /Rect. + let td: Vec = text.split("Tf ").nth(1).unwrap().split_whitespace().take(2).map(|v| v.parse().unwrap()).collect(); + let size: f64 = text.split("/Helv ").nth(1).unwrap().split_whitespace().next().unwrap().parse().unwrap(); + assert!(td[0] > 45.0 && td[0] + appearance::text_width("\u{b6}", size) <= 60.0 + 1e-6 && td[1] >= 30.0, "{text}"); + assert!(!text.contains("60 30 c h f"), "the caret no longer reaches the right edge: {text}"); + for none in ["/Sy /None", ""] { + let (c, font) = caret(none); + assert!(!has_pilcrow(&c) && !font, "{none}"); + assert!(String::from_utf8_lossy(&c).contains("60 30 c h f"), "{none}: the caret fills /Rect as before"); + } + // With /RD leaving room on the right, the caret keeps its box and the symbol fits beside it. + let (c, _) = caret("/Sy /P /RD [0 0 15 0]"); + let text = String::from_utf8_lossy(&c).into_owned(); + assert!(text.contains("45 30 c h f") && has_pilcrow(&c), "{text}"); + // Malformed /RD and a degenerate /Rect don't panic. + for rd in ["/RD [40 0 40 0]", "/RD [-1 0 0 0]", "/RD [1 2]", "/RD (x)"] { + assert!(has_pilcrow(&caret(&format!("/Sy /P {rd}")).0), "{rd}"); + } + let doc = document_of(&[b"<< /Subtype /Caret /Rect [30 30 30 30] /Sy /P >>"]); + let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap(); + assert!(appearance::build(&d).is_some()); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1..057350a29 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7438,3 +7438,53 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() { assert!(pages[0].images.is_empty(), "the picture is past the budget"); assert_eq!(truncated, [0]); } + +/// ISO 32000-2 Tables 166 and 183, drawn on the page: a square without an appearance whose +/// `/Border [0 0 3 [6 3]]` names its width before a dash array is stroked 3 pt wide and dashed +/// (it was 1 pt and solid), and a caret with `/Sy /P` shows a ¶ to its right. +#[test] +fn legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn() { + let original = Arc::new(pdf_with_annots(&[ + b"<< /Type /Annot /Subtype /Square /Rect [20 180 120 280] /Border [0 0 3 [6 3]] /C [1 0 0] >>", + b"<< /Type /Annot /Subtype /Caret /Rect [20 20 80 80] /Sy /P /C [0 0 1] >>", + ])); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("noap.pdf", None, original, None).expect("opens"); + let p = shown(&s, id, 0); + let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + // y down: the bottom edge's stroke is centred 1.5 pt inside /Rect, on row 118. + let row: Vec> = (24..116).map(|x| px(x, 118)).collect(); + let red = row.iter().filter(|c| c[0] > 200 && c[1] < 80).count(); + let white = row.iter().filter(|c| c[1] > 200).count(); + assert!(red > 40 && white > 15, "3 pt and dashed: {red} red, {white} white"); + // The ¶ sits in the right part of the caret's /Rect, where the caret no longer reaches. + let blue = (250..270).flat_map(|y| (68..80).map(move |x| (x, y))).filter(|&(x, y)| { + let c = px(x, y); + c[2] > 150 && c[0] < 100 + }); + assert!(blue.count() > 20, "the pilcrow is drawn beside the caret"); +} + +/// A one-page 200 × 300 document whose page lists `annots` (objects 4, 5, …). +fn pdf_with_annots(annots: &[&[u8]]) -> Vec { + let list: String = (0..annots.len()).map(|i| format!("{} 0 R ", i + 4)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{list}] >>"); + let mut objs: Vec<&[u8]> = + vec![b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", page.as_bytes()]; + objs.extend_from_slice(annots); + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(o); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + bytes +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c..8a02e8760 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3572,8 +3572,8 @@ milestone = "M5" status = "partial" commands = [] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text", "crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn"] -notes = "appearances for every type PdfCraft creates (highlight multiplies; clouds, callouts, stamps, polygons, carets); rich text not yet" +evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text", "crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::legacy_border_width_and_dash_are_drawn", "crates/annot/src/tests.rs::ink_draws_its_border_style_dashes", "crates/annot/src/tests.rs::a_caret_with_a_paragraph_symbol_draws_the_pilcrow", "crates/engine/src/tests.rs::legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn"] +notes = "appearances for every type PdfCraft creates (highlight multiplies; clouds, callouts, stamps, polygons, carets). Comments other files carry without an appearance are drawn from their dictionaries the same way, including a legacy /Border width and dash array (ISO 32000-2 Table 166), dashed ink (/BS, Table 182) and a caret's ¶ for /Sy /P (Table 183). Not yet: rich text, beveled, inset and underline border styles, sound icons" [[feature]] id = "comment.sticky-note" From 63510e29eb0e73d1aa1fed62c9c0a0f3848103a9 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:12:33 -0400 Subject: [PATCH 49/59] test(ui): docstrings for the recovery tests' folder helper --- crates/ui-egui/src/recovery.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index c21e2366b..8870878ed 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -435,12 +435,15 @@ mod idle_tests { std::fs::create_dir(&path).unwrap(); Self(path) } + + /// The folder. fn path(&self) -> &Path { &self.0 } } impl Drop for TestDir { + /// Remove the folder and what the test left in it. fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } From a9e6069f32a67dca7fda6e4f8570b304275f5bec Mon Sep 17 00:00:00 2001 From: bmh129 <11478682+bmh129@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:52:00 -0500 Subject: [PATCH 50/59] M3: resize and move the window again right after a resize or move (Wayland) ViewportCommand::BeginResize and StartDrag hand the pointer to the compositor, which keeps the button's release on Wayland; winit reports only that the pointer left, and egui doesn't count that as a release. egui then still thought the primary button held, so the next press on an edge or the tab strip didn't start a drag until a click inside the window. window_controls::desktop_grab notes each hand-over, and release_after_desktop_grab (called first in raw_input_hook) adds the missed release to the next input, once, unless that input brings one. Ported from WordCraft 1581c288, confirmed by hand there for repeated edge resizes on GNOME/Wayland. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wrwtfrnom6Eg1Jt7xZ7VA8 --- crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/window_controls.rs | 59 ++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 5e8de9375..2723d16c2 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -40,7 +40,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let full = ui.max_rect(); let drag = ui.interact(full, ui.id().with("titledrag"), Sense::click_and_drag()); if drag.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + window_controls::desktop_grab(ui.ctx(), egui::ViewportCommand::StartDrag); } if drag.double_clicked() { let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index af9912f45..9dcee8cac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3125,6 +3125,8 @@ impl eframe::App for PdfCraftApp { /// would keep the place the pointer entered at: tell it where the pointer really is, so the /// page grid can show (and use) the gap under it. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // The button's release a window move or resize kept from egui. + window_controls::release_after_desktop_grab(ctx, raw); // Before egui reads this frame's wheel input. self.scroll_speed.apply(ctx, raw); if raw.hovered_files.is_empty() && raw.dropped_files.is_empty() { diff --git a/crates/ui-egui/src/window_controls.rs b/crates/ui-egui/src/window_controls.rs index eacf854c2..838ce29db 100644 --- a/crates/ui-egui/src/window_controls.rs +++ b/crates/ui-egui/src/window_controls.rs @@ -217,7 +217,7 @@ pub fn resize_edges(ctx: &egui::Context) { let response = ui.interact(rect, ui.id().with(("edge", n)), Sense::drag()).on_hover_cursor(cursor(direction)); // Sensing only drags, a drag starts on the press itself. if response.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::BeginResize(direction)); + desktop_grab(ui.ctx(), egui::ViewportCommand::BeginResize(direction)); } } // Without the desktop's frame or shadow, a hairline keeps the window apart from @@ -231,6 +231,33 @@ pub fn resize_edges(ctx: &egui::Context) { }); } +fn grab_id() -> egui::Id { + egui::Id::new("window_controls_desktop_grab") +} + +/// Hand the pointer to the desktop to move or resize the window. On Wayland the compositor keeps +/// the button's release and winit reports only that the pointer left, which egui doesn't count as +/// a release; egui would think the button still held, and the next press on an edge or the title +/// bar wouldn't start a drag until a click inside the window. The release comes back on the next +/// input (`release_after_desktop_grab`). +pub fn desktop_grab(ctx: &egui::Context, command: egui::ViewportCommand) { + ctx.send_viewport_cmd(command); + ctx.data_mut(|d| d.insert_temp(grab_id(), ())); + ctx.request_repaint(); +} + +/// After `desktop_grab`, add the primary button's release to the next input, unless it brings one. +pub fn release_after_desktop_grab(ctx: &egui::Context, raw: &mut egui::RawInput) { + if ctx.data_mut(|d| d.remove_temp::<()>(grab_id())).is_none() { + return; + } + let released = raw.events.iter().any(|e| matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. })); + if !released { + let (pos, modifiers) = ctx.input(|i| (i.pointer.interact_pos().unwrap_or_default(), i.modifiers)); + raw.events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers }); + } +} + #[cfg(test)] mod tests { use super::{Button, Layout, ResizeDirection, zones}; @@ -285,4 +312,34 @@ mod tests { assert!(r.width() >= 0.0 && r.height() >= 0.0); } } + + #[test] + fn a_desktop_grab_gives_the_button_release_back() { + let ctx = egui::Context::default(); + let releases = |raw: &egui::RawInput| { + raw.events.iter().filter(|e| matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. })).count() + }; + let mut raw = egui::RawInput::default(); + super::release_after_desktop_grab(&ctx, &mut raw); + assert_eq!(releases(&raw), 0, "no grab, no release"); + + super::desktop_grab(&ctx, egui::ViewportCommand::StartDrag); + super::release_after_desktop_grab(&ctx, &mut raw); + assert_eq!(releases(&raw), 1, "the release the desktop kept"); + + let mut raw = egui::RawInput::default(); + super::release_after_desktop_grab(&ctx, &mut raw); + assert_eq!(releases(&raw), 0, "only once"); + + super::desktop_grab(&ctx, egui::ViewportCommand::BeginResize(ResizeDirection::East)); + let mut raw = egui::RawInput::default(); + raw.events.push(egui::Event::PointerButton { + pos: Pos2::ZERO, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + super::release_after_desktop_grab(&ctx, &mut raw); + assert_eq!(releases(&raw), 1, "not doubled when the input brings it"); + } } From 0b63a73c04edacd5873190970bfb31854c150afc Mon Sep 17 00:00:00 2001 From: bmh129 <11478682+bmh129@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:45:02 -0500 Subject: [PATCH 51/59] M3: progress docs for repeated window resizes and moves on Wayland Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wrwtfrnom6Eg1Jt7xZ7VA8 --- ROADMAP.md | 1 + docs/roadmap.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5634d6bf2..cf19e0149 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -191,6 +191,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. diff --git a/docs/roadmap.md b/docs/roadmap.md index bb542aefc..cdfd1dd52 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -153,7 +153,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. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). | | 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. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | 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. 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). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. | +| 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). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | @@ -239,6 +239,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | M3: on GNOME/Wayland, a second resize by an edge or move by the tab strip works without a click in between (the compositor kept the button's release from egui). Estimates unchanged. | | 2026-10-22 | minor | M4, #3916 partial: edits and Undo/Redo cancel Organize page drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | | 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | From 185c2dbc362163c4b7cae97113443879cde5030b Mon Sep 17 00:00:00 2001 From: bmh129 <11478682+bmh129@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:58:06 -0500 Subject: [PATCH 52/59] M3: give the kept button release back before the next press (Wayland) The release a desktop grab kept from egui was appended to the next input, so when that input carried the press of a second resize or move the button ended the frame up and the drag stopped. It now goes before the input's first primary press; only a release ahead of any press counts as the kept one, since a quick click's release belongs to its own press. Found by CodeRabbit in review of #3970. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017rPQFoRmQbw8vWhGgGVrRs --- crates/ui-egui/src/window_controls.rs | 57 ++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/window_controls.rs b/crates/ui-egui/src/window_controls.rs index 838ce29db..d02ea5dc8 100644 --- a/crates/ui-egui/src/window_controls.rs +++ b/crates/ui-egui/src/window_controls.rs @@ -247,15 +247,23 @@ pub fn desktop_grab(ctx: &egui::Context, command: egui::ViewportCommand) { } /// After `desktop_grab`, add the primary button's release to the next input, unless it brings one. +/// The release goes before the input's first press (the next resize or move, or a click), which +/// egui would otherwise end straight away; a release after that press is the press's own. pub fn release_after_desktop_grab(ctx: &egui::Context, raw: &mut egui::RawInput) { if ctx.data_mut(|d| d.remove_temp::<()>(grab_id())).is_none() { return; } - let released = raw.events.iter().any(|e| matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. })); - if !released { - let (pos, modifiers) = ctx.input(|i| (i.pointer.interact_pos().unwrap_or_default(), i.modifiers)); - raw.events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers }); - } + let first = raw.events.iter().enumerate().find_map(|(n, e)| match e { + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => Some((n, *pressed)), + _ => None, + }); + let at = match first { + Some((_, false)) => return, + Some((n, true)) => n, + None => raw.events.len(), + }; + let (pos, modifiers) = ctx.input(|i| (i.pointer.interact_pos().unwrap_or_default(), i.modifiers)); + raw.events.insert(at, egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers }); } #[cfg(test)] @@ -342,4 +350,43 @@ mod tests { super::release_after_desktop_grab(&ctx, &mut raw); assert_eq!(releases(&raw), 1, "not doubled when the input brings it"); } + + #[test] + fn the_release_goes_before_the_press_of_the_next_grab() { + let button = |pressed| egui::Event::PointerButton { + pos: Pos2::new(5.0, 5.0), + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + let primary = |raw: &egui::RawInput| { + raw.events + .iter() + .filter_map(|e| if let egui::Event::PointerButton { pressed, .. } = e { Some(*pressed) } else { None }) + .collect::>() + }; + let ctx = egui::Context::default(); + let frame = |events: Vec, grab: Option| { + let mut raw = egui::RawInput { events, ..Default::default() }; + super::release_after_desktop_grab(&ctx, &mut raw); + let order = primary(&raw); + ctx.run_ui(raw, |ui| { + if let Some(command) = grab.clone() { + super::desktop_grab(ui.ctx(), command); + } + }) + .textures_delta + .clear(); + order + }; + // The press of the first resize starts the grab; the desktop keeps its release. + frame(vec![egui::Event::PointerMoved(Pos2::new(5.0, 5.0)), button(true)], Some(egui::ViewportCommand::BeginResize(ResizeDirection::East))); + // The next input brings the press of the second resize. + assert_eq!(frame(vec![button(true)], None), [false, true], "the kept release, then the new press"); + assert!(ctx.input(|i| i.pointer.primary_down()), "the second resize holds the button"); + + // A quick click's release belongs to its own press, not to the grab before it. + frame(vec![], Some(egui::ViewportCommand::StartDrag)); + assert_eq!(frame(vec![button(true), button(false)], None), [false, true, false]); + } } From 70d92a052bbc19ff60fa31578ad37275d499e5ee Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:21:39 +0000 Subject: [PATCH 53/59] Integration: #2289 keep Contents, BM, Lang and AF on the split widget --- crates/organize/src/import.rs | 4 ++++ crates/organize/src/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 15087a0c5..3e56c0d53 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -59,6 +59,10 @@ const WIDGET_KEYS: &[&[u8]] = &[ b"OC", b"Tabs", b"A", + b"Contents", + b"BM", + b"Lang", + b"AF", ]; struct Copier<'a> { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index a9364e83f..85c319915 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1014,6 +1014,30 @@ fn splitting_a_combined_widget_keeps_annotation_actions_and_field_triggers() { } } +#[test] +fn splitting_a_combined_widget_keeps_its_annotation_only_keys() { + let mut src = doc_a(); + let list = page_dict(&src, 1).get(b"Annots").and_then(|a| a.as_array()).cloned().unwrap(); + let widget = + list.iter().filter_map(Object::as_ref).find(|r| src.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))).unwrap(); + src.update_dict(widget, |d| { + d.set(b"Contents".to_vec(), Object::String(PdfString::text("alt text"))); + d.set(b"BM".to_vec(), Object::name("Multiply")); + d.set(b"Lang".to_vec(), Object::String(PdfString::text("en-US"))); + d.set(b"AF".to_vec(), Object::Array(Vec::new())); + }) + .unwrap(); + let out = full_roundtrip(&extract_pages(&src, &[1, 1]).unwrap()); + for i in 0..2 { + let w = annots(&out, i).into_iter().find(|d| d.name(b"Subtype") == Some(b"Widget")).unwrap(); + let parent = out.get(w.reference(b"Parent").unwrap()).as_dict().unwrap().clone(); + for key in [b"Contents".as_slice(), b"BM", b"Lang", b"AF"] { + assert!(w.contains(key), "the widget keeps {key:?}"); + assert!(!parent.contains(key), "the parent field does not take {key:?}"); + } + } +} + #[test] fn repeated_page_gets_independent_annotations() { // A1 has a link, A2 a link and a form field, A3 a note with a popup; each is listed twice. From c79c15791e93b14a7d4fb9f09846b2383602a00b Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:35:31 +0000 Subject: [PATCH 54/59] Integration: #4147 test literals take Annotation::no_rotate (#3016) and context_menu's can_modify --- crates/ui-egui/src/comments.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 05ccd5f77..2d2571863 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -2496,6 +2496,7 @@ mod tests { intent: None, fill_sign: false, points: Vec::new(), + no_rotate: false, }], ..Default::default() }; @@ -2505,7 +2506,7 @@ mod tests { let mut h = egui_kittest::Harness::new_ui_state( |ui, (view, actions): &mut (DocView, Vec)| { ui.button("Page").context_menu(|ui| { - if let Some(a) = context_menu(ui, view, &info, &prefs, true) { + if let Some(a) = context_menu(ui, view, &info, &prefs, true, true) { actions.push(a); } }); From b3efc422d96419e0a019bc25801ed5a883332b78 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:51:32 +0000 Subject: [PATCH 55/59] =?UTF-8?q?Integration:=20comments=20tests=20use=20s?= =?UTF-8?q?upport::settle=20(#r3-t06=20shared=20helper=20=C3=97=20r3-t0x?= =?UTF-8?q?=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/tests/it/comments.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index bfb90746e..9f142c8f7 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -674,7 +674,7 @@ fn a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn() { app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); app.set_option("quick", "note").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); let page = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); // Screen pixels per point of the displayed page (300 × 200 pt turned to 200 × 300). let k = page.width() / 200.0; @@ -684,7 +684,7 @@ fn a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn() { h.run_steps(2); h.get_by_label("Post").click(); h.run_steps(3); - settle(&mut h); + crate::support::settle(&mut h); let c = comments(&h); assert_eq!(c.len(), 1); assert_eq!((c[0].subtype.as_str(), c[0].no_rotate), ("Text", true)); @@ -1398,7 +1398,7 @@ fn flatten_comments_from_the_page_menu_and_the_panel_menu() { author: "Tester".into(), })); h.state_mut().views[0].comments.selected = None; - settle(&mut h); + crate::support::settle(&mut h); assert_eq!(comments(&h).len(), 1); // Right-click on the page, away from the comment. From b4691a3a7cddacce7f3eeb29a4c86774a6dcf7bf Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:53:10 +0000 Subject: [PATCH 56/59] Integration: cargo fmt --- crates/xfa/src/tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 8421c6554..46c243531 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -3489,7 +3489,6 @@ fn rendering_keeps_the_forms_own_font_objects() { assert!(fonts.as_dict().unwrap().reference(b"Helv").is_some(), "and the layout's fonts as objects"); } - /// Text is measured in the standard-14 face it is drawn in, at the widths ISO 32000-2 Annex D /// gives: bold and Times are no longer Helvetica scaled, and the horizontal scale still applies. #[test] From 1a2178924e4d6ae0986b0e3ac92787428f0cffc1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:57:45 +0000 Subject: [PATCH 57/59] =?UTF-8?q?Integration:=20the=20page's=20decode=20bu?= =?UTF-8?q?dget=20owns=20the=20form=20count=20in=20redaction=20(#3670=20?= =?UTF-8?q?=C3=97=20main)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3670 raised the form depth limit to 50 and added its own 50,000-visit cap. Main's per-page DecodeBudget (e2a677164) has the same 50,000-form limit, and the visit cap fired first, so Apply removed the forms instead of refusing the page as main does. The visit cap goes; the fan-out test expects the refusal, and the depth-limit sanitization test builds its chain from MAX_DEPTH. --- crates/redact/src/interp.rs | 13 ++++--------- crates/redact/src/tests.rs | 23 +++++++++++------------ 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index ee772cd1a..5b91bb64c 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -41,7 +41,6 @@ pub(crate) const MAX_DEPTH: usize = 50; /// at 50 is a hang rather than a limit. Edit text's reader pairs its depth limit with the same /// budget, and this is that constant, so the two cannot drift apart. Running out is treated as a /// form that cannot be read: removed in Apply, counted as unreadable in Verify. -pub(crate) const MAX_FORM_VISITS: usize = pdfcraft_edit::text::MAX_FORM_VISITS; // A form a reader still shows must be walkable here, or a redaction removes content that is // visible; the build says so rather than a test, so the two limits cannot drift apart. @@ -129,8 +128,6 @@ pub(crate) struct Scope<'a> { /// shared forms drawn from other shared forms would otherwise multiply the work with every /// level. budget: DecodeBudget, - /// Forms entered so far in this pass ([`MAX_FORM_VISITS`]). - visits: usize, } impl<'a> Scope<'a> { @@ -152,7 +149,6 @@ impl<'a> Scope<'a> { used_names: Vec::new(), depth: 0, budget, - visits: 0, } } @@ -1291,10 +1287,10 @@ fn xobject( { return None; } - // Too deep, or too many forms for one page: either way this form cannot be read, so - // it goes (Apply) or the page is left alone (Verify), as a form that will not decode - // does. The budget is what keeps a fan-out or a cycle from costing 2^50 walks. - if scope.depth >= MAX_DEPTH || scope.visits >= MAX_FORM_VISITS { + // Too deep: this form cannot be read, so it goes (Apply) or the page is left alone + // (Verify), as a form that will not decode does. The page's decode budget below is what + // keeps a fan-out or a cycle from costing 2^50 walks. + if scope.depth >= MAX_DEPTH { if scope.mode == Mode::Verify { // Sanitization removes a Form it cannot enter, so the verification scan // must count that conservative removal or it may decide to skip the pass. @@ -1308,7 +1304,6 @@ fn xobject( out.doomed.push(r); return Some(None); } - scope.visits += 1; // Strict, like page content: a Form read only in part counts as undecodable. let data = if scope.budget.enter_form() { scope.budget.decode_strict(s) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 9065826fb..d89b3c608 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -347,7 +347,7 @@ fn a_deeply_nested_form_keeps_the_text_nobody_marked() { /// can send. The pass also counts the forms it enters, and a page past that budget is treated as /// one it cannot read. #[test] -fn a_form_that_fans_out_or_loops_is_bounded_by_the_visit_budget() { +fn a_form_that_fans_out_or_loops_is_bounded_by_the_page_budget() { /// A page drawing `Fm1`, where each of `n` forms draws the next **twice** and the last draws /// text: 2^n paths if nothing stops the walk. With `looping`, the last form draws the first /// one again instead, so the chain has no end at all. @@ -383,13 +383,12 @@ fn a_form_that_fans_out_or_loops_is_bounded_by_the_visit_budget() { let start = std::time::Instant::now(); let mut doc = fan_out(crate::interp::MAX_DEPTH, looping); mark(&mut doc, 0, &[[5.0, 275.0, 60.0, 295.0]], ""); - let report = apply(&mut doc, None).unwrap(); + let result = apply(&mut doc, None); let took = start.elapsed(); assert!(took < std::time::Duration::from_secs(20), "looping {looping}: the walk took {took:?}"); - // The page was still redacted, and the budget stopped the walk rather than the pass. - assert!(report.forms_removed > 0, "looping {looping}: the forms past the budget are removed"); - let c = content(&doc, 0); - assert!(!c.contains("(HEAD)"), "looping {looping}: the marked text is gone: {c}"); + // The page's form budget stops the walk, and Apply refuses the page rather than + // removing forms it could not read. + assert!(result.is_err(), "looping {looping}: the page past its form budget is refused"); } } @@ -824,19 +823,19 @@ fn form_chain_at_max_depth_is_counted_and_removed_by_hidden_layer_sanitization() use crate::sanitize::{Hidden, remove_hidden, scan}; const PRIVATE: &[u8] = b"PRIVATE_FORM_BEYOND_MAX_DEPTH"; - // MAX_DEPTH is 12. The final Form is encountered at that depth and is removed - // conservatively without being entered. Verify must count that removal or sanitize - // can skip the HiddenLayers pass entirely. + // The final Form is encountered at MAX_DEPTH and is removed conservatively without being + // entered. Verify must count that removal or sanitize can skip the HiddenLayers pass entirely. + let max = crate::interp::MAX_DEPTH; let mut forms = Vec::new(); - for depth in 0..=12 { - let content = if depth == 12 { + for depth in 0..=max { + let content = if depth == max { b"BT /F1 10 Tf 10 100 Td (PRIVATE_FORM_BEYOND_MAX_DEPTH) Tj ET".to_vec() } else { format!("/F{} Do", depth + 1).into_bytes() }; forms.push(stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", &content)); } - let xobjects = (0..=12).map(|depth| format!("/F{depth} {} 0 R", 7 + depth)).collect::>().join(" "); + let xobjects = (0..=max).map(|depth| format!("/F{depth} {} 0 R", 7 + depth)).collect::>().join(" "); let mut doc = one_page(b"/F0 Do", &format!("/XObject << {xobjects} >>"), forms); assert!( write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE), From 9b986f8981842e3c4633ea99b0d6758d35a5c078 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:59:28 +0000 Subject: [PATCH 58/59] =?UTF-8?q?Integration:=20drop=20the=20doc=20comment?= =?UTF-8?q?=20of=20the=20removed=20visit=20cap=20(#3670=20=C3=97=20main)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/redact/src/interp.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 5b91bb64c..3380fece1 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -36,12 +36,6 @@ use pdfcraft_fonts::pdf::Metrics; /// visible. pub(crate) const MAX_DEPTH: usize = 50; -/// How many forms one pass enters. The depth limit alone bounded the walk while it was 12: a form -/// that draws the next level twice, or a cycle of two forms, costs 2^depth interpretations, which -/// at 50 is a hang rather than a limit. Edit text's reader pairs its depth limit with the same -/// budget, and this is that constant, so the two cannot drift apart. Running out is treated as a -/// form that cannot be read: removed in Apply, counted as unreadable in Verify. - // A form a reader still shows must be walkable here, or a redaction removes content that is // visible; the build says so rather than a test, so the two limits cannot drift apart. const _: () = assert!(MAX_DEPTH >= pdfcraft_edit::text::MAX_FORM_DEPTH); From 91b6f7f1726ec94918755d17d9e46a7195c2e8aa Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 07:06:18 +0000 Subject: [PATCH 59/59] Integration: #3670's fan-out test allows for a loaded machine --- crates/redact/src/tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index d89b3c608..9ffb07e6f 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -385,7 +385,8 @@ fn a_form_that_fans_out_or_loops_is_bounded_by_the_page_budget() { mark(&mut doc, 0, &[[5.0, 275.0, 60.0, 295.0]], ""); let result = apply(&mut doc, None); let took = start.elapsed(); - assert!(took < std::time::Duration::from_secs(20), "looping {looping}: the walk took {took:?}"); + // An unbounded walk would never end; the limit leaves room for a loaded test machine. + assert!(took < std::time::Duration::from_secs(120), "looping {looping}: the walk took {took:?}"); // The page's form budget stops the walk, and Apply refuses the page rather than // removing forms it could not read. assert!(result.is_err(), "looping {looping}: the page past its form budget is refused");