From 6091deb829c57b2c018ac892a6e455a64c0f6ebf Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:25:25 +0200 Subject: [PATCH 01/16] M7: preserve overlapping text when dragging paragraphs (#594) (#1052) * M7 #594: preserve ordinary paragraph drags (source-only GGEO candidate) [skip ci] Route body drags through source-preserving MoveObjects, bind gestures to the document generation, and keep added-content input ownership with the Edit panel open or closed. Add nine real-shell source regressions. Source-only transfer to the owner's fork, explicitly authorized before validation. Independently source-reviewed; formatting, builds, tests, rendering and all gates remain pending on ggeo-ai02. No upstream PR. AI-assisted with OpenAI. * M7 #594: record validated ordinary paragraph drags Format the source-reviewed candidate and record successful GGEO validation: focused UI/engine tests, exact-base source loss, native drag/history/save/reopen, independent Poppler checks, and all eight repository CI gates. Credit: @FredApps * M7: use generic validation wording * Tests: pin the clock where two saves or combines are compared byte for byte Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: FredApps Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 4 + crates/engine/src/tests.rs | 3 +- crates/ui-egui/src/canvas.rs | 8 +- crates/ui-egui/src/edit_text_ui.rs | 28 ++- crates/ui-egui/tests/bookmarks.rs | 3 + crates/ui-egui/tests/editing.rs | 386 ++++++++++++++++++++++++++++- docs/roadmap.md | 3 + 7 files changed, 423 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 11eaf3422..23bba1a45 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -139,6 +140,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-11 (M7, #594):** Ordinary paragraph body drags now use the existing source-preserving object translation path; edge drags still rewrap. An in-progress drag is discarded if the document generation changes, and added-content gestures keep their own edit handler. Added real-shell source regressions for overlapping and identical labels, history, save/reopen, partial overlap, kerning, rotation/crop/UserUnit and atomic refusal. Linux validation passed: focused UI/engine regressions, exact-base source-loss failures, native drag/undo/redo/save/reopen with independent Poppler renders, and all eight CI gates. Text replacement and resizing over unrelated overlapping content remain outside this repair. Estimates unchanged. + - **2026-10-10 (community, M7, Thai Add Text):** Optional craft-fonts Thai face (Noto Sans Thai) for the UI and shaped Add Text through the engine, CLI and MCP. Stacked marks and Sara Am are kept through Type 3 clusters with ToUnicode; regular style, and wrapping at cluster boundaries (no dictionary word segmentation). The craft-fonts pin for CI and releases moves to the revision with the Thai face. Forms and Fill & Sign are outside this change. Estimates unchanged. - **2026-10-11 (#995, #1077, permissions password after opening):** A PDF with only a permissions password opens without asking, restricted, and nothing let the user enter that password afterwards: the notice and the hint pointed at reopening with it. Now the security notice and Properties ▸ Security offer Unlock…, and Change settings / Remove security on a restricted document ask for the permissions password first and then carry on (as Acrobat does). The open password is refused there. `Session::unlock` upgrades the open document in place (owner and user passwords unlock the same file key, so nothing is re-read), unlocks the undo and redo states and reopens saves as owner; it is not an edit and doesn't mark the document unsaved. New command `protect.unlock`; the new strings are in all 15 full catalogs. Tests cover crypt, cos, engine (undo, redo, save, separate open password) and the UI. Still missing: an automation tool for it. Estimates unchanged. - **2026-10-11 (M14, #307 Plan 3):** Engine open and full refresh use one page/content traversal to populate marks and added content. The independent public scans remain available; the combined scanner borrows direct objects and holds referenced objects through `Arc` instead of cloning scan-only `/Contents` arrays or `/PCAdded` dictionaries. Differential tests cover an indirect `/PCAdded` dictionary as well as ordering, malformed/direct/indirect/repeated entries and `/UserUnit`. Exact `bbbe600`→final release CLI measurements used 10 alternating pairs: wall-time medians were 183.645→174.440 ms on 10,000 marker-free pages (10/10 wins), 33.587→32.752 ms on 1,500 mixed pages (8/10), and 24.331→20.133 ms on the 100,000-entry array stress fixture (10/10). The stress fixture's maximum-RSS median fell 28,655,616→19,038,208 B; peak-footprint medians were 14,747,136→14,673,408 B. From P0, the follow-up stress run was 20.886→20.201 ms (10/10), with RSS 23,953,408→19,087,360 B and peak footprint 14,706,144→14,697,952 B. `doc_open` stdout hashes matched every pair. The ancillary `organize` binding preserves the prior `prune_legacy` update condition. Estimates unchanged. @@ -349,6 +352,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. | | 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged | | 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) | | 2026-10-10 | minor | Essentials-user score added (≈ 66%) | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6b99cec4b..b043cdd93 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -525,7 +525,8 @@ fn create_multiple_reads_each_source_only_after_the_previous_one_is_released() { #[test] fn create_multiple_gives_the_bytes_of_combine_ranges_and_refuses_a_bad_source() { - let s = Session::new(); + // The two combines are compared byte for byte, and each stamps the clock: pin it. + let s = Session::new().with_clock(|| 1_700_000_000); let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2))); let sources: Vec = vec![("a".into(), a.clone(), Some("1-2".into())), ("b".into(), b, None), ("a".into(), a.clone(), Some("3".into()))]; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3306b6d33..f829c9a3b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2018,7 +2018,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. let preparing = app.is_preparing(); // Edit a PDF: added text and images can be selected, moved and edited. - let editing_content = (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || app.quick_tool == QuickTool::AddText; + 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 view = &mut app.views[index]; // Opened without the owner password and something is restricted. @@ -2627,7 +2628,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { crate::content_ui::page_input(ui, &resp, &xf, i, info, &added, tool == QuickTool::AddText, &text_style, view) || tool == QuickTool::AddText }; - let on_edit_text = !on_objects && tool == QuickTool::EditText && can_modify && { + // Added content owns its gesture; its source text must not queue a second edit. + let on_edit_text = !on_objects && !on_content && tool == QuickTool::EditText && can_modify && { let generation = doc.edit_generation(); let lines = match view.edit_lines.get(&i) { Some((g, l)) if *g == generation => l.clone(), @@ -2647,7 +2649,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; // Images first (they can sit under text boxes' corners); then paragraphs. crate::edit_text_ui::image_input(ui, &resp, &xf, i, info, &images, view, &mut image_action) - || crate::edit_text_ui::page_input(ui, &resp, &xf, i, info, &lines, view) + || crate::edit_text_ui::page_input(ui, &resp, &xf, i, doc, &lines, view) }; let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view); let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 6dbe70b88..bf807146a 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -5,7 +5,7 @@ //! its proportions), right-click for rotate, flip, replace, save and delete; Delete removes it. use egui::{Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke}; -use pdfcraft_engine::Edit; +use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; @@ -223,6 +223,8 @@ enum Grip { pub struct BlockDrag { page: usize, block: usize, + /// A paragraph index belongs to the document generation where the press was resolved. + generation: u64, /// Where the drag started (screen). start: Pos2, grip: Grip, @@ -427,10 +429,15 @@ pub(crate) fn page_input( resp: &egui::Response, xf: &PageXform, page: usize, - info: &DocInfo, + doc: &pdfcraft_engine::Document, lines: &[pdfcraft_engine::TextBlock], view: &mut DocView, ) -> bool { + let info = &doc.info; + if view.block_drag.is_some_and(|d| d.generation != doc.edit_generation()) { + view.block_drag = None; + return true; + } let boxes: Vec = lines.iter().map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)).collect(); let painter = ui.painter(); let active = view.line_editor.as_ref().filter(|e| e.page == page).map(|e| e.block); @@ -470,18 +477,25 @@ pub(crate) fn page_input( if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) { view.block_drag = None; let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview)); - let style = if resizing { + let edit = if resizing { // The new width in user space: the box's change, added to the paragraph's own. A // moved left side (in user space; on an upside-down page that's the screen's right // edge) moves the paragraph with it. let width = (l.rect[2] - l.rect[0]) + (to[2] - to[0]) - (from[2] - from[0]); let shift = to[0] - from[0]; - pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() } + let style = pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() }; + Edit::EditTextBlock { page, block: d.block, text: l.text.clone(), style } } else { - pdfcraft_engine::BlockStyle { offset: Some([to[0] - from[0], to[1] - from[1]]), ..Default::default() } + // Translation must keep the selected source operators. Rewriting unchanged text + // would also remove unrelated paragraphs that now overlap this one (#594). + Edit::MoveObjects { + page, + objects: vec![ObjectTarget { kind: ObjectKind::Text, index: d.block }], + offset: [to[0] - from[0], to[1] - from[1]], + } }; if preview != b { - view.pending_edit = Some(Edit::EditTextBlock { page, block: d.block, text: l.text.clone(), style }); + view.pending_edit = Some(edit); } } return true; @@ -495,7 +509,7 @@ pub(crate) fn page_input( && let Some(o) = ui.input(|i| i.pointer.press_origin()) && let Some((block, grip)) = grab_at(&boxes, o, upright) { - view.block_drag = Some(BlockDrag { page, block, start: o, grip }); + view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip }); return true; } let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false }; diff --git a/crates/ui-egui/tests/bookmarks.rs b/crates/ui-egui/tests/bookmarks.rs index 846ba59f1..6d94fb633 100644 --- a/crates/ui-egui/tests/bookmarks.rs +++ b/crates/ui-egui/tests/bookmarks.rs @@ -165,6 +165,9 @@ fn search_finds_collapsed_bookmarks_and_keeps_original_paths() { h.run_steps(3); assert!(h.query_by_label("Résumé results").is_none()); let id = h.state().views[0].id; + // A full save stamps /ModDate from the clock: pin it, or two saves a second apart differ. + let session = std::mem::take(&mut h.state_mut().session); + h.state_mut().session = session.with_clock(|| 1_700_000_000); let before = h.state().session.save_full_bytes(id).unwrap(); h.get_by_label("Search").click(); h.run_steps(1); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 4acc49902..ba4bbefa0 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2269,7 +2269,7 @@ fn dragging_a_paragraph_moves_it_and_its_edge_rewraps_it() { assert_eq!(moved.text, "Page 1"); let near = |a: f64, b: f64| (a - b).abs() < 1.5; assert!(near(moved.rect[0], before.rect[0] + 30.0) && near(moved.rect[1], before.rect[1] + 50.0), "{:?} → {:?}", before.rect, moved.rect); - assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Edit text")); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move object")); // Drag the handle on its right edge in to about 45 pt wide: "Page" and "1" rewrap onto two lines. let edge = screen(moved.rect[2], (moved.rect[1] + moved.rect[3]) / 2.0) + egui::vec2(2.0, 0.0); let narrower = (moved.rect[2] - moved.rect[0] - 45.0) as f32 * k; @@ -2279,9 +2279,393 @@ fn dragging_a_paragraph_moves_it_and_its_edge_rewraps_it() { let lines: Vec = doc.text_lines(0).iter().map(|l| l.text.clone()).collect(); assert_eq!(lines, ["Page", "1"], "rewrapped to the narrower box"); assert!(near(doc.text_lines(0)[0].rect[0], moved.rect[0]), "it keeps its place"); + assert_eq!(doc.can_undo(), Some("Edit text"), "edge drags still rewrap text"); assert!(h.state().views[0].line_editor.is_none()); } +/// Contributor-original text, with an off-origin CropBox. Keep the moving paragraph last in +/// source order so an ordinary hit picks it again when two independent boxes fully overlap. +fn paragraph_drag_fixture(content: &str, rotation: i64, unit: i64) -> Vec { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + doc.update_dict(page, |d| { + d.set(b"Contents".to_vec(), Object::Ref(contents)); + d.set(b"MediaBox".to_vec(), Object::Array([0, 0, 400, 500].map(Object::Int).to_vec())); + d.set(b"CropBox".to_vec(), Object::Array([20, 30, 380, 470].map(Object::Int).to_vec())); + d.set(b"Rotate".to_vec(), Object::Int(rotation)); + d.set(b"UserUnit".to_vec(), Object::Int(unit)); + }) + .unwrap(); + pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap() +} + +fn paragraph_drag_harness(bytes: Vec, view_rotation: u16) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.session = pdfcraft_engine::Session::new().with_clock(|| 1_700_000_000); + app.set_option("language", "en").unwrap(); + app.open_bytes("paragraphs.pdf", None, bytes).unwrap(); + app.set_option("zoom", "75").unwrap(); + app.views[0].rotation = view_rotation; + assert!(app.execute("edit.edit_text")); + app + }); + h.run_steps(4); + h +} + +fn dragged_paragraphs(h: &Harness<'static, PdfCraftApp>) -> Vec { + let app = h.state(); + app.session.get(app.views[0].id).unwrap().text_blocks(0) +} + +/// Resolve the current inventory and transform for every gesture, never an old block index +/// or cached screen position. With identical labels, choose the last (topmost) source box. +fn paragraph_drag_points(h: &Harness<'static, PdfCraftApp>, text: &str, offset: [f64; 2]) -> (egui::Pos2, egui::Pos2) { + let app = h.state(); + let view = &app.views[0]; + let doc = app.session.get(view.id).unwrap(); + let b = doc.text_blocks(0).into_iter().rfind(|b| b.text == text).unwrap(); + let xf = pdfcraft_ui_egui::canvas::PageXform { + rect: view.page_screen_rect(0).unwrap(), + rot: view.rotation, + pw: doc.info.pages[0].width, + ph: doc.info.pages[0].height, + }; + let moved = std::array::from_fn(|i| (b.rect[i] + offset[i % 2]) as f32); + (xf.user_rect(&doc.info, 0, b.rect.map(|v| v as f32)).center(), xf.user_rect(&doc.info, 0, moved).center()) +} + +fn drag_paragraph(h: &mut Harness<'static, PdfCraftApp>, text: &str, offset: [f64; 2]) { + let (from, to) = paragraph_drag_points(h, text, offset); + h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + assert!(h.state().views[0].line_editor.is_none()); + drag(h, from, to); + assert!(h.state().views[0].line_editor.is_none(), "a body drag must not open the typing editor"); + h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); +} + +fn assert_dragged_paragraphs(actual: &[pdfcraft_engine::TextBlock], expected: &[pdfcraft_engine::TextBlock]) { + assert_eq!(actual.len(), expected.len(), "independent paragraphs must survive"); + // Source order is itself an invariant, including when text and geometry are identical. + for (a, e) in actual.iter().zip(expected) { + assert_eq!((&a.text, &a.base_font, a.size, a.bold, a.italic, a.color), (&e.text, &e.base_font, e.size, e.bold, e.italic, e.color)); + assert_eq!(a.lines.len(), e.lines.len(), "moving must not rewrap"); + assert!(a.rect.iter().zip(e.rect).all(|(a, e)| (a - e).abs() < 0.01), "{a:?} != {e:?}"); + } +} + +/// Read only the current page contents, not stale revisions or the renderer's deduplicated +/// text layer. These synthetic fixtures contain each literal exactly once per source draw. +fn paragraph_drag_content(h: &Harness<'static, PdfCraftApp>) -> String { + let app = h.state(); + let doc = pdfcraft_cos::Document::open(app.session.get(app.views[0].id).unwrap().bytes.clone()).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let contents = doc.resolve(page.dict.get(b"Contents").unwrap()); + let pieces = match contents.as_array() { + Some(a) => a.clone(), + None => vec![contents.as_ref().clone()], + }; + let mut bytes = Vec::new(); + for piece in pieces { + let object = doc.resolve(&piece); + let pdfcraft_cos::Object::Stream(stream) = object.as_ref() else { panic!("fixture content must be a stream") }; + bytes.extend(stream.decoded().unwrap()); + } + String::from_utf8(bytes).unwrap() +} + +fn save_paragraph_drag(h: &mut Harness<'static, PdfCraftApp>, name: &str, original: &[u8]) -> Vec { + let out = temp_path(name); + let id = h.state().views[0].id; + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.key_press_modifiers(Modifiers::COMMAND, Key::S); + h.run_steps(4); + let saved = std::fs::read(&out).unwrap(); + assert!(saved.starts_with(original), "Save keeps the opened/saved revisions"); + assert!(!h.state().session.get(id).unwrap().dirty); + assert_eq!(h.state().session.get(id).unwrap().bytes.as_slice(), saved.as_slice()); + std::fs::remove_file(out).unwrap(); + saved +} + +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"); + let directory = std::path::Path::new(&directory); + std::fs::create_dir_all(directory).unwrap(); + h.render().unwrap().save(directory.join(name)).unwrap(); + } +} + +fn check_paragraph_overlap_drag(identical: bool, reopen_between: bool) { + let (stationary, moving) = if identical { ("Repeated", "Repeated") } else { ("Stationary beta", "Moving alpha") }; + let content = + format!("BT /F1 12 Tf 60 220 Td ({stationary}) Tj ET BT /F1 12 Tf 250 100 Td (Keep here) Tj ET BT /F1 12 Tf 60 320 Td ({moving}) Tj ET"); + let mut saved_base = paragraph_drag_fixture(&content, 0, 1); + let mut h = paragraph_drag_harness(saved_base.clone(), 0); + let before = dragged_paragraphs(&h); + assert_eq!(before.len(), 3); + paragraph_drag_shot(&mut h, &format!("before-{identical}-{reopen_between}.png")); + let moving_index = before.iter().rposition(|b| b.text == moving).unwrap(); + let mut overlap = before.clone(); + for i in [1, 3] { + overlap[moving_index].rect[i] -= 100.0; + } + drag_paragraph(&mut h, moving, [0.0, -100.0]); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap); + paragraph_drag_shot(&mut h, &format!("overlap-{identical}-{reopen_between}.png")); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &before); + assert!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().is_none(), "the first release was exactly one step"); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(4); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap); + + if reopen_between { + let bytes = save_paragraph_drag(&mut h, &format!("paragraph-overlap-{identical}.pdf"), &saved_base); + saved_base = bytes.clone(); + h = paragraph_drag_harness(bytes, 0); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap); + } + // A rewrite here used to remove the stationary box, even if its text was different. + drag_paragraph(&mut h, moving, [150.0, 0.0]); + let mut after = overlap.clone(); + for i in [0, 2] { + after[moving_index].rect[i] += 150.0; + } + assert_dragged_paragraphs(&dragged_paragraphs(&h), &after); + paragraph_drag_shot(&mut h, &format!("after-{identical}-{reopen_between}.png")); + let current = paragraph_drag_content(&h); + assert_eq!(current.matches(&format!("({stationary})")).count(), if identical { 2 } else { 1 }); + assert_eq!(current.matches(&format!("({moving})")).count(), if identical { 2 } else { 1 }); + assert_eq!(current.matches("(Keep here)").count(), 1); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move object")); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap); + if !reopen_between { + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &before); + } + assert!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().is_none(), "each release was exactly one step"); + if !reopen_between { + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(4); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap); + } + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(4); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &after); + assert!(h.state().session.get(h.state().views[0].id).unwrap().can_redo().is_none()); + let bytes = save_paragraph_drag(&mut h, &format!("paragraph-separated-{identical}-{reopen_between}.pdf"), &saved_base); + let reopened = paragraph_drag_harness(bytes, 0); + assert_dragged_paragraphs(&dragged_paragraphs(&reopened), &after); + assert_eq!(paragraph_drag_content(&reopened), current); +} + +#[test] +fn ordinary_paragraph_drags_preserve_overlapping_text_and_history() { + check_paragraph_overlap_drag(false, false); +} + +#[test] +fn ordinary_paragraph_drags_preserve_identical_overlapping_labels() { + check_paragraph_overlap_drag(true, false); +} + +#[test] +fn ordinary_paragraph_drags_preserve_overlap_after_save_and_reopen() { + check_paragraph_overlap_drag(false, true); + check_paragraph_overlap_drag(true, true); +} + +#[test] +fn ordinary_paragraph_drags_preserve_partial_overlap_and_touching_labels() { + let content = "BT /F1 12 Tf 60 220 Td (Repeated) Tj ET BT /F1 12 Tf 60 320 Td (Repeated) Tj ET"; + for fraction in [0.25, 0.75, 1.0] { + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0); + let mut expected = dragged_paragraphs(&h); + assert_eq!(expected.len(), 2); + let width = expected[1].rect[2] - expected[1].rect[0]; + // 75% overlap, 25% overlap, then exact touching of the original source rectangles. + for offset in [[width * fraction, -100.0], [150.0, -40.0]] { + drag_paragraph(&mut h, "Repeated", offset); + for (i, coordinate) in expected[1].rect.iter_mut().enumerate() { + *coordinate += offset[i % 2]; + } + assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected); + assert_eq!(paragraph_drag_content(&h).matches("(Repeated)").count(), 2); + } + } +} + +#[test] +fn ordinary_paragraph_drags_preserve_multiline_kerning_strings() { + let content = + concat!("BT /F1 12 Tf 60 220 Td (Keep here) Tj ET ", "BT /F1 12 Tf 14 TL 60 320 Td [(First) -120 ( line)] TJ T* (Second line) Tj ET"); + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0); + let mut expected = dragged_paragraphs(&h); + assert_eq!(expected.len(), 2); + assert_eq!(expected[1].lines.len(), 2); + let text = expected[1].text.clone(); + for offset in [[0.0, -100.0], [150.0, 0.0]] { + drag_paragraph(&mut h, &text, offset); + for (i, coordinate) in expected[1].rect.iter_mut().enumerate() { + *coordinate += offset[i % 2]; + } + assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected); + let current = paragraph_drag_content(&h); + assert!(current.contains("[(First) -120 ( line)] TJ"), "original encoded strings and kerning stay byte-identical"); + for literal in ["(Keep here)", "(Second line)"] { + assert_eq!(current.matches(literal).count(), 1); + } + } +} + +#[test] +fn ordinary_paragraph_drag_respects_rotations_crop_and_user_unit() { + let content = "BT /F1 12 Tf 60 220 Td (Keep here) Tj ET BT /F1 12 Tf 60 320 Td (Moving alpha) Tj ET"; + for rotation in [0, 90, 180, 270] { + for view_rotation in [0, 90, 180, 270] { + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, rotation, 2), view_rotation); + let mut expected = dragged_paragraphs(&h); + let moving = expected.iter_mut().find(|b| b.text == "Moving alpha").unwrap(); + for (i, coordinate) in moving.rect.iter_mut().enumerate() { + *coordinate += [35.0, -27.0][i % 2]; + } + drag_paragraph(&mut h, "Moving alpha", [35.0, -27.0]); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected); + } + } +} + +#[test] +fn an_ordinary_added_text_drag_keeps_its_content_edit() { + for panel_open in [true, false] { + check_added_text_drag(panel_open); + } +} + +fn check_added_text_drag(panel_open: bool) { + let content = "BT /F1 12 Tf 60 220 Td (Keep here) Tj ET"; + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0); + let id = h.state().views[0].id; + h.state_mut() + .session + .apply( + id, + pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { text: "Added note".into(), rect: [100.0, 330.0, 230.0, 354.0], ..Default::default() }, + }, + ) + .unwrap(); + let original = h.state().session.save_bytes(id).unwrap(); + h = paragraph_drag_harness(original.as_ref().clone(), 0); + h.state_mut().left_open = panel_open; + h.run_steps(4); + let added = |h: &Harness<'static, PdfCraftApp>| { + let app = h.state(); + app.session.get(app.views[0].id).unwrap().added.clone() + }; + let before_added = added(&h); + assert_eq!(before_added.len(), 1); + let before = dragged_paragraphs(&h); + assert_eq!(before.len(), 2, "added text is also visible in the raw paragraph list"); + let mut expected = before.clone(); + let moved = expected.iter_mut().find(|b| b.text == "Added note").unwrap(); + for (i, coordinate) in moved.rect.iter_mut().enumerate() { + *coordinate += [30.0, -20.0][i % 2]; + } + // The same pointer hits added content and its source glyphs. Only content_ui may own it: + // a Text target would be excluded from MoveObjects and overwrite the valid UpdateContent. + let (from, to) = paragraph_drag_points(&h, "Added note", [30.0, -20.0]); + drag(&mut h, from, to); + assert!(h.state().views[0].line_editor.is_none()); + assert!(h.state().views[0].block_drag.is_none()); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected); + let after_added = added(&h); + assert_eq!(after_added.len(), 1); + let (old_rect, new_rect) = (before_added[0].content.rect(), after_added[0].content.rect()); + for i in 0..4 { + assert!((new_rect[i] - old_rect[i] - [30.0, -20.0][i % 2]).abs() < 0.01); + } + assert_eq!(after_added[0].content, before_added[0].content.with_rect(new_rect)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Edit content")); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + assert_eq!(added(&h), before_added); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &before); + assert!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().is_none(), "one content edit, no paragraph rewrite"); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(4); + assert_eq!(added(&h), after_added); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected); + let bytes = save_paragraph_drag(&mut h, &format!("paragraph-added-content-{panel_open}.pdf"), original.as_slice()); + let reopened = paragraph_drag_harness(bytes, 0); + assert_eq!(added(&reopened), after_added); + assert_dragged_paragraphs(&dragged_paragraphs(&reopened), &expected); +} + +#[test] +fn an_ordinary_paragraph_drag_refusal_never_rewrites_the_text() { + let content = "q BT 4 Tr /F1 12 Tf 60 320 Td (Clipping text) Tj ET Q"; + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0); + let before = dragged_paragraphs(&h); + let id = h.state().views[0].id; + let bytes = h.state().session.get(id).unwrap().bytes.clone(); + let generation = h.state().session.get(id).unwrap().edit_generation(); + drag_paragraph(&mut h, "Clipping text", [30.0, -20.0]); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.bytes, bytes); + assert_eq!(doc.edit_generation(), generation); + assert!(doc.can_undo().is_none()); + assert!(!doc.dirty); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &before); + assert!(h.state().toast.as_ref().unwrap().0.contains("Text used as a clipping path cannot be moved with this selection.")); +} + +#[test] +fn an_ordinary_paragraph_drag_cannot_reuse_an_index_after_an_edit() { + let content = "BT /F1 12 Tf 60 120 Td (Discard) Tj ET BT /F1 12 Tf 60 320 Td (Moving alpha) Tj ET BT /F1 12 Tf 60 220 Td (Keep here) Tj ET"; + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0); + let (from, to) = paragraph_drag_points(&h, "Moving alpha", [30.0, -20.0]); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + h.hover_at(to); + h.run_steps(2); + assert!(h.state().views[0].block_drag.is_some()); + let id = h.state().views[0].id; + h.state_mut() + .session + .apply(id, pdfcraft_engine::Edit::EditTextBlock { page: 0, block: 0, text: String::new(), style: Default::default() }) + .unwrap(); + let after_edit = dragged_paragraphs(&h); + assert_eq!(after_edit.len(), 2, "the surviving paragraphs get different indexes"); + assert_eq!(after_edit[1].text, "Keep here", "the old target index now names an unrelated paragraph"); + let generation = h.state().session.get(id).unwrap().edit_generation(); + h.run_steps(2); + h.drop_at(to); + h.run_steps(4); + assert!(h.state().views[0].block_drag.is_none()); + assert_eq!(h.state().session.get(id).unwrap().edit_generation(), generation, "the stale release adds no edit"); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &after_edit); +} + /// A quick flick: press at `from`, and the next frame already finds the pointer at `to`. fn flick(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2) { h.hover_at(from); diff --git a/docs/roadmap.md b/docs/roadmap.md index fc8cf7857..4fde3d4a3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; M6: respect existing form-font encodings; retain source fonts on appearance fallback; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (direct Organize overview and page actions; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756, #817–#821, #809, #819, #816, #844; earlier: localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -79,6 +80,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | @@ -135,6 +137,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | M7: #594 ordinary paragraph drags validated in the Linux test environment with source-loss baseline failures, UI/engine regressions, native save/reopen and all eight CI gates. Estimates unchanged. | | 2026-10-11 | minor | M4: direct Organize overview, checkbox selection and individual page actions. Estimates unchanged. | | 2026-10-11 | minor | Synced with closed issues: encrypted output opens in Acrobat (#774, #836), so gap 1 left rank 0 and convert and share (32–55 h, the gap's 8–15 h removed); organize, XFDF and multi-object move fixes (#817, #821, #809, #819, #844); the #816 panic and the #799–#821 batch fixed; rank 1 names web printing (Windows shipped, #756). Stage and readiness numbers not re-measured | | 2026-10-11 | minor | M6: respect existing form-font encodings; retain source fonts on appearance fallback. Estimates unchanged. | From 29dca4608914a688423f94807c11260049b077a8 Mon Sep 17 00:00:00 2001 From: Raj Patel Date: Sun, 11 Oct 2026 16:27:48 -0600 Subject: [PATCH 02/16] Bound text search result allocation (#1164) Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/automation/src/lib.rs | 5 ++++- crates/render/src/text.rs | 21 +++++++++++++++++++++ docs/roadmap.md | 4 +++- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 23bba1a45..a893c2ef9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-11 (M13, #971):** `text_find` now bounds each page's temporary match-range list to the remaining result limit plus one, which is enough to detect and return a continuation without allocating every match on a dense page. Added limited-search coverage; the existing end-to-end continuation test passes. The text cache byte budget and per-page extraction warnings remain open. - **2026-10-11 (M7, #594):** Ordinary paragraph body drags now use the existing source-preserving object translation path; edge drags still rewrap. An in-progress drag is discarded if the document generation changes, and added-content gestures keep their own edit handler. Added real-shell source regressions for overlapping and identical labels, history, save/reopen, partial overlap, kerning, rotation/crop/UserUnit and atomic refusal. Linux validation passed: focused UI/engine regressions, exact-base source-loss failures, native drag/undo/redo/save/reopen with independent Poppler renders, and all eight CI gates. Text replacement and resizing over unrelated overlapping content remain outside this repair. Estimates unchanged. - **2026-10-10 (community, M7, Thai Add Text):** Optional craft-fonts Thai face (Noto Sans Thai) for the UI and shaped Add Text through the engine, CLI and MCP. Stacked marks and Sara Am are kept through Type 3 clusters with ToUnicode; regular style, and wrapping at cluster boundaries (no dictionary word segmentation). The craft-fonts pin for CI and releases moves to the revision with the Thai face. Forms and Fill & Sign are outside this change. Estimates unchanged. diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 1d76b40c8..5b7d7fe3e 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1639,7 +1639,10 @@ impl Automation { let texts = self.page_texts(id, &pages)?; for (p, text) in pages.iter().zip(&texts) { let skip_here = if *p == start { skip } else { 0 }; - for (k, r) in text.find(query).into_iter().enumerate().skip(skip_here) { + // One extra match proves that the caller needs a continuation. Include skipped + // matches on the starting page when bounding the temporary per-page result. + let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1); + for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) { if matches.len() == limit { next = Some(json!({ "start_page": p + 1, "skip": k })); break 'search; diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 5b70b17cf..de791e5ad 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -95,6 +95,14 @@ impl PageText { /// Search with Acrobat's find options: case-sensitive, whole words only. pub fn find_opts(&self, needle: &str, case_sensitive: bool, whole_words: bool) -> Vec> { + self.find_opts_limited(needle, case_sensitive, whole_words, usize::MAX) + } + + /// Search with Acrobat's find options, returning at most `limit` glyph ranges. + pub fn find_opts_limited(&self, needle: &str, case_sensitive: bool, whole_words: bool, limit: usize) -> Vec> { + if limit == 0 { + return Vec::new(); + } let fold = |s: &str| if case_sensitive { s.to_string() } else { fold_case(s) }; let needle: Vec = fold(needle).split_whitespace().collect::>().join(" ").chars().collect(); if needle.is_empty() { @@ -124,6 +132,9 @@ impl PageText { let start = chars[i].1; let end = chars[i + needle.len() - 1].1 + 1; out.push(start..end); + if out.len() == limit { + break; + } i += needle.len(); } else { i += 1; @@ -1466,9 +1477,19 @@ mod tests { assert_eq!(t.find("quick\tbrown"), vec![0..12]); assert_eq!(t.find("quick"), vec![0..5]); assert_eq!(t.find("brown"), vec![7..12]); + assert_eq!(t.find_opts_limited("o", false, false, 0), Vec::new()); assert!(t.find("quick absent").is_empty()); } + #[test] + fn limited_search_stops_after_the_requested_matches() { + let mut glyphs = Vec::new(); + word(&mut glyphs, "oooo", 10.0, 10.0, 6.0); + let text = layout(glyphs); + assert_eq!(text.find_opts_limited("o", false, false, 2), vec![0..1, 1..2]); + assert_eq!(text.find_opts_limited("o", false, false, 10), vec![0..1, 1..2, 2..3, 3..4]); + } + fn word(v: &mut Vec, s: &str, x: f32, y: f32, advance: f32) { for (i, c) in s.chars().enumerate() { let x0 = x + i as f32 * advance; diff --git a/docs/roadmap.md b/docs/roadmap.md index 4fde3d4a3..154be7d4d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (M13/#971: bound per-page search match allocation; text cache byte budget and extraction warnings remain open) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; M6: respect existing form-font encodings; retain source fonts on appearance fallback; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (direct Organize overview and page actions; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -90,7 +91,7 @@ total matches the parity estimate. | 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). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, web printing | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools).
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA | -| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
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. | +| 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. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | @@ -137,6 +138,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | M13/#971: cap materialized text-search matches to the requested limit plus one continuation match. | | 2026-10-11 | minor | M7: #594 ordinary paragraph drags validated in the Linux test environment with source-loss baseline failures, UI/engine regressions, native save/reopen and all eight CI gates. Estimates unchanged. | | 2026-10-11 | minor | M4: direct Organize overview, checkbox selection and individual page actions. Estimates unchanged. | | 2026-10-11 | minor | Synced with closed issues: encrypted output opens in Acrobat (#774, #836), so gap 1 left rank 0 and convert and share (32–55 h, the gap's 8–15 h removed); organize, XFDF and multi-object move fixes (#817, #821, #809, #819, #844); the #816 panic and the #799–#821 batch fixed; rank 1 names web printing (Windows shipped, #756). Stage and readiness numbers not re-measured | From 82b709d5163d9dafd95dad57a11afaffc52e6b30 Mon Sep 17 00:00:00 2001 From: ysrdevs <32823553+ysrdevs@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:29:35 -0500 Subject: [PATCH 03/16] XFA: import and export data files, and pictures for image fields (#1175) Export Data on an XFA form writes its data as Acrobat does: XML (the data alone, rooted at the form's root data node, its namespaces declared) or an XDP holding the whole datasets and a reference to the PDF (.xdp, a new format). Import Data takes an XDP, a datasets or data element, or a data document whose root is the form's root data node, and makes it the form's whole data, keeping data descriptions; a laid-out form is laid out again from it (rows added and removed) and its calculations run; XFDF, FDF and tables still go the AcroForm way. Undoable. Image fields are now buttons marked /PCXfaImage (no PDF action): the forms layer treats one as an image field, so clicking it (or form_set_image) picks a picture, which is kept in the data as base64 with its xfa:contentType, as Designer saves it, and the laid-out form shows it from there. xfa: replace_data, write_data_image (hostile content types cleaned, 32 MB cap), export_data, import_data (64 MB cap, the parser's node limit, self-closing data elements). engine: export routing by document kind, import routing by the file's root element, re-layout and calculation after the data changes outside the fields, the picture's content type from its bytes. UI: XML and XDP in the export dialog of an XFA form, .xdp in the import filter. Tool texts, README and parity (form.xfa-data) updated. Hardened after an adversarial review pass: the file's data is held as a tree and written with the packet's own namespace prefix (never its markup), every packet rewrite is parsed back before it is written, files capped at 16 MB and 64 levels (a tag scan before the recursive parser, also guarding the datasets parser), pictures at 8 MB and PNG/ JPEG/GIF only, the file's shape and root name checked, UTF-16 and ISO-8859-1 decoded, pictures into data groups refused, cleaned names for pictures, static forms empty the fields the file doesn't hold, more rows than the layout cap reported, one datasets stream per edit, and only image fields take pictures into the data. --- crates/automation/src/forms.rs | 3 +- crates/automation/src/tools.rs | 6 +- crates/automation/tests/automation.rs | 33 ++ crates/engine/src/lib.rs | 174 ++++++++++- crates/engine/src/tests.rs | 133 ++++++++ crates/engine/src/xfa.rs | 11 + crates/forms/src/lib.rs | 5 + crates/ui-egui/src/files.rs | 14 +- crates/xfa/README.md | 14 +- crates/xfa/src/data.rs | 429 +++++++++++++++++++++++++- crates/xfa/src/layout.rs | 27 +- crates/xfa/src/lib.rs | 1 + crates/xfa/src/pdf.rs | 11 + crates/xfa/src/tests.rs | 226 +++++++++++++- crates/xfdf/src/lib.rs | 6 +- parity/acrobat-features.toml | 12 +- 16 files changed, 1072 insertions(+), 33 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index e6334489c..7c60425a8 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -520,7 +520,8 @@ impl Automation { let id = self.doc(a)?.id; let path = self.resolve(a.str("path")?, true)?; let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); - let format = pdfcraft_engine::DataFormat::from_extension(&ext).ok_or_else(|| bad("the path must end in .xfdf, .fdf, .xml, .csv or .txt"))?; + let format = + pdfcraft_engine::DataFormat::from_extension(&ext).ok_or_else(|| bad("the path must end in .xfdf, .fdf, .xml, .xdp, .csv or .txt"))?; let (comments, fields) = match a.opt_str("what")?.unwrap_or("all") { "all" => (true, true), "comments" => (true, false), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c712ab389..0e0f5116e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -345,7 +345,7 @@ pub fn tools() -> Vec { }), &["doc", "page", "path", "at"], )), - t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). Undoable.") + t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.") .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])), t( "form_set_props", @@ -388,10 +388,10 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }), &["doc"], )), - t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). what: all (default), comments, fields.") + t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.") .destructive() .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])), - t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. Undoable.") + t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.") .destructive() diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 5f8a928c3..0298b396b 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3534,6 +3534,39 @@ fn exporting_to_word_html_and_rtf() { assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err()); } +#[test] +fn xfa_data_files_export_and_import_through_tools() { + let dir = workdir("xfa-data"); + let data = "
JP
"; + std::fs::write(dir.join("fields.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "fields.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.xml", "what": "fields" })); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.xdp", "what": "fields" })); + let xml = std::fs::read_to_string(dir.join("data.xml")).unwrap(); + let xdp = std::fs::read_to_string(dir.join("data.xdp")).unwrap(); + assert!(xml.contains("JP"), "{xml}"); + assert!(xdp.contains("JP"), "{xdp}"); + // Edit the exported data and import it back. + std::fs::write(dir.join("edited.xdp"), xdp.replace("JP", "CA")).unwrap(); + let r = ok(&mut a, "doc_import_data", json!({ "doc": doc, "path": "edited.xdp" })); + assert!(r["filled_fields"].as_u64().unwrap() >= 1); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let country = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "country").unwrap(); + assert_eq!(country["value"], json!("CA")); + // A picture for the image field, through form_set_image, lands in the data. + std::fs::write(dir.join("pic.gif"), pdfcraft_xfa::fixtures::TINY_GIF).unwrap(); + ok(&mut a, "form_set_image", json!({ "doc": doc, "field": "photo", "path": "pic.gif" })); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "after.xml", "what": "fields" })); + let after = std::fs::read_to_string(dir.join("after.xml")).unwrap(); + assert!(after.contains("R0lGODlh"), "{after}"); + // XDP is for XFA forms only. + std::fs::write(dir.join("plain.pdf"), pdfcraft_xfa::fixtures::static_shell("Ada")).unwrap(); + let plain = ok(&mut a, "doc_open", json!({ "path": "plain.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_data", json!({ "doc": plain, "path": "static.xml", "what": "fields" })); + assert!(std::fs::read_to_string(dir.join("static.xml")).unwrap().contains("Ada"), "a static XFA form exports its data too"); +} + #[test] fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() { let dir = workdir("xfa"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ffc22681a..218b0779b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2029,7 +2029,34 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), _ => (1, 1), }; - pdfcraft_forms::set_button_icon(doc, name, img, px)?; + // An XFA image field keeps the picture in the data too (as Designer saves it), and a + // laid-out form shows it from there: so only pictures the layout can show, and only + // for image fields (a laid-out one carries its SOM path; a static form's field name + // is its path). + let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); + let image_field = is_xfa + && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); + if image_field { + let Some(content_type) = image_content_type(image) else { + return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG or GIF picture; choose one of those"))); + }; + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); + let path = match cx.xfa.as_deref() { + Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), + None => pdfcraft_xfa::som_to_path(&som), + }; + let r = + pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; + if !r.written && r.warnings.is_empty() { + return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); + } + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + xfa_after_data_change(doc, cx, false)?; + } else { + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + } } Edit::ResetForm { names } => { pdfcraft_forms::reset(doc, names.as_deref())?; @@ -2174,7 +2201,24 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> } } Edit::ImportData { bytes, .. } => { - pdfcraft_xfdf::import(doc, bytes)?; + // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; + // XFDF, FDF, Acrobat's XML and tables go the usual way. + let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); + match text { + Some(Ok(text)) if is_xfa_data_file(&text) => { + let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); + let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) + .map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); + xfa_after_data_change(doc, cx, true)?; + } + Some(Err(why)) => return Err(EditError::Invalid(why)), + _ => { + pdfcraft_xfdf::import(doc, bytes)?; + } + } } Edit::RemoveHidden { which } => { pdfcraft_redact::sanitize::remove_hidden(doc, which)?; @@ -2508,6 +2552,117 @@ fn note_warnings(list: &mut Vec, new: &[String]) { } } +/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or +/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an +/// encoding this can't read. +fn decode_data_file(bytes: &[u8]) -> Option> { + if bytes.starts_with(b"%FDF") { + return None; + } + let declared = |text: &str| -> Option { + let head = text.get(..text.len().min(256))?; + let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; + let enc = decl.split_once("encoding=")?.1; + let quote = enc.chars().next()?; + Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) + }; + if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { + match declared(&text).as_deref() { + None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { + // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 + // differs, and is rare in form data). + return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); + } + Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), + } + } + // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. + let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); + match declared(&latin1).as_deref() { + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), + _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), + } +} + +/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), +/// rather than XFDF, Acrobat's `` XML or a table? By its root element. +fn is_xfa_data_file(text: &str) -> bool { + let text = text.get(..text.len().min(65_536)).unwrap_or(text); + let mut rest = text.trim_start_matches('\u{feff}').trim_start(); + // Past the XML declaration, comments and processing instructions. + let mut guard = 0; + while guard < 100 { + guard += 1; + if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); + } else { + break; + } + } + let Some(tag) = rest.strip_prefix('<') else { return false }; + let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); + let local = name.rsplit(':').next().unwrap_or(name); + !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") +} + +/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. +fn image_content_type(bytes: &[u8]) -> Option<&'static str> { + match pdfcraft_xfa::image::kind(bytes)? { + pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), + pdfcraft_xfa::image::Kind::Png => Some("image/png"), + pdfcraft_xfa::image::Kind::Gif => Some("image/gif"), + } +} + +/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is +/// laid out again from the data (then, when `calculate`, its calculations run), and the +/// fields take the data's values. +fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { + if let Some(tpl) = cx.xfa.clone() { + let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; + let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) + .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = r { + cx.xfa_out.errors.push(e); + } + } + let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; + if !taken.is_empty() { + cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); + } + // Not laid out again (a static form, or scripts off): fields the data no longer holds are + // emptied, so the data replaced is the data shown. + if cx.xfa.is_none() && calculate { + let data = xfa_field_data(doc); + let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); + for f in pdfcraft_forms::fields(doc) { + if present.contains(&f.name) || f.value.is_empty() { + continue; + } + use pdfcraft_forms::FieldKind as K; + let empty = match f.kind { + K::Text => FieldValue::Text(String::new()), + K::CheckBox => FieldValue::Check(false), + K::Radio => FieldValue::Radio(None), + K::Combo | K::List => FieldValue::Choice(Vec::new()), + K::PushButton | K::Signature => continue, + }; + if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { + cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); + } + } + } + Ok(()) +} + /// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns /// the names of the fields that changed. fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { @@ -3406,11 +3561,20 @@ impl Session { pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? } }; + // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. + let xfa = doc.info.xfa.is_some(); + if format == DataFormat::Xdp && !xfa { + return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); + } guard(|| match format { - DataFormat::Xfdf => pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes(), - DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name), - other => pdfcraft_xfdf::export_data(&cos, other).into_bytes(), + DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), + DataFormat::Fdf => Ok(pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name)), + DataFormat::Xml | DataFormat::Xdp if xfa => { + pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) + } + other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), }) + .map_err(EditError::Write)? .map_err(EditError::Write) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b043cdd93..75d52e1d8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2634,6 +2634,139 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() { assert!(taken[0].contains("langs") && !taken[0].contains("country"), "{}", taken[0]); } +#[test] +fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() { + use pdfcraft_xfa::som_to_path; + let data = "
JPEnglish\nSpanish
"; + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("fields.pdf", None, bytes, None).expect("opens"); + let field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned().unwrap_or_else(|| panic!("no field {n}")); + // Export: XML is the datasets document, XDP wraps it; the fields' current values are in it. + s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["CA".into()]) }).unwrap(); + let xml = String::from_utf8(s.export_data(id, DataFormat::Xml, false, true).unwrap()).unwrap(); + assert!(xml.starts_with("CA") && !xml.contains("CA") && xdp.contains("href=\"fields.pdf\""), "{xdp}"); + // Import: the data file's values replace the form's; the calculation runs; undo brings + // the old values back. + let file = "
FRFrench1234
"; + s.apply(id, Edit::ImportData { name: "data.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap(); + assert_eq!(field(&s, "country").value, vec!["FR".to_string()]); + assert_eq!(field(&s, "langs").value, vec!["French".to_string()]); + assert_eq!(field(&s, "pin").value, vec!["1234".to_string()]); + assert_eq!(field(&s, "summary").value, vec!["Country: FR".to_string()], "{:?}", s.take_js_output(id)); + s.undo(id).unwrap(); + assert_eq!(field(&s, "country").value, vec!["CA".to_string()]); + s.redo(id).unwrap(); + assert_eq!(field(&s, "country").value, vec!["FR".to_string()]); + // A file in another encoding, and one with a long comment before its root, import too. + let latin1: Vec = + "
Société
".chars().map(|c| c as u32 as u8).collect(); + s.apply(id, Edit::ImportData { name: "l1.xml".into(), bytes: Arc::new(latin1) }).unwrap(); + assert_eq!(field(&s, "other").value, vec!["Société".to_string()]); + let commented = format!("
after
", "x".repeat(5000)); + s.apply(id, Edit::ImportData { name: "c.xml".into(), bytes: Arc::new(commented.into_bytes()) }).unwrap(); + assert_eq!(field(&s, "other").value, vec!["after".to_string()]); + let utf16: Vec = "
wide
".encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + s.apply(id, Edit::ImportData { name: "w.xml".into(), bytes: Arc::new([&[0xFF, 0xFE][..], &utf16].concat()) }).unwrap(); + assert_eq!(field(&s, "other").value, vec!["wide".to_string()]); + let shift_jis = b"
".to_vec(); + assert!(s.apply(id, Edit::ImportData { name: "sj.xml".into(), bytes: Arc::new(shift_jis) }).is_err()); + // A data file for another form is refused. + assert!(s.apply(id, Edit::ImportData { name: "o.xml".into(), bytes: Arc::new(b"1".to_vec()) }).is_err()); + assert_eq!(field(&s, "other").value, vec!["wide".to_string()]); + // XFDF still goes the usual way on an XFA form. + let xfdf = + "9999"; + s.apply(id, Edit::ImportData { name: "d.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap(); + assert_eq!(field(&s, "pin").value, vec!["9999".to_string()]); + assert_eq!(field(&s, "country").value, vec!["FR".to_string()]); + // A picture chosen for the image field goes into the data and onto the page. + let png = pdfcraft_xfa::fixtures::tiny_png(); + assert_eq!(field(&s, "photo").button, Some(pdfcraft_forms::af::ButtonAction::ImportIcon), "clicking it picks a picture"); + s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let d = pdfcraft_xfa::data_of(&cos).unwrap(); + assert!(d.text_at(&som_to_path("form[0].page1[0].photo[0]")).unwrap().starts_with("R0lGODlh")); + assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains("xfa:contentType=\"image/gif\"")); + assert_eq!(s.get(id).unwrap().page_images(0).len(), 2, "the field's picture (now the GIF) and the logo"); + // Only pictures the form can show; only image fields. + assert!(s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(b"BM\0\0\0\0".to_vec()) }).is_err()); + assert!(s.apply(id, Edit::SetFieldImage { name: "country".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).is_err()); + // One datasets stream per edit, however many steps it took. + let before = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap().object_numbers().len(); + s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "pin".into(), value: FieldValue::Text("1".into()) }).unwrap(); + let after = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let datasets = after + .object_numbers() + .into_iter() + .filter(|n| matches!(&*after.get(pdfcraft_cos::ObjRef { num: *n, generation: 0 }), pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|b| b.starts_with(b"Ada1")); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("static.pdf", None, original, None).expect("opens"); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name.ends_with(n)).map(|f| f.value.clone()).unwrap_or_default(); + assert_eq!(value(&s, "name[0]"), vec!["Ada".to_string()]); + assert_eq!(value(&s, "agree[0]"), vec!["1".to_string()]); + // The file names only `name`: `agree` is emptied too, since the data was replaced. + s.apply(id, Edit::ImportData { name: "d.xml".into(), bytes: Arc::new(b"Bob".to_vec()) }).unwrap(); + assert_eq!(value(&s, "name[0]"), vec!["Bob".to_string()]); + assert!(value(&s, "agree[0]").is_empty(), "{:?}", value(&s, "agree[0]")); + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let d = pdfcraft_xfa::data_of(&cos).unwrap(); + assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Bob")); + assert!(d.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")).is_none_or(str::is_empty)); + let xml = String::from_utf8(s.export_data(id, DataFormat::Xml, false, true).unwrap()).unwrap(); + assert!(xml.contains("Bob") && !xml.contains("1"), "{xml}"); +} + +#[test] +fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("rows.pdf", None, bytes, None).expect("opens"); + let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default(); + assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 1); + let file = "3a10b32c0.5
"; + s.apply(id, Edit::ImportData { name: "rows.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap(); + assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 3, "{:?}", names(&s)); + assert_eq!(value(&s, "total"), vec!["15".to_string()], "qty 3 × price 5, calculated after the import: {:?}", s.take_js_output(id)); + assert_eq!(value(&s, "grand"), vec!["42.5".to_string()]); + // More rows than the layout shows: the document says so. + let rows: String = (0..60).map(|i| format!("r{i}1")).collect(); + let file = format!("
1{rows}
"); + s.apply(id, Edit::ImportData { name: "many.xml".into(), bytes: Arc::new(file.into_bytes()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("60 rows")), "{out:?}"); + // A file with one row takes the others away. + let file = "
1only7
"; + s.apply(id, Edit::ImportData { name: "one.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap(); + assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 1, "{:?}", names(&s)); + assert_eq!(value(&s, "grand"), vec!["7".to_string()]); + // Not an XFA data file on an XFA form: refused with the usual message, nothing changed. + assert!(s.apply(id, Edit::ImportData { name: "junk.xml".into(), bytes: Arc::new(b"".to_vec()) }).is_err()); + assert_eq!(value(&s, "grand"), vec!["7".to_string()]); + // The automation tool's view: export then import through a file round-trips the rows. + let out = s.export_data(id, DataFormat::Xdp, false, true).unwrap(); + assert!(String::from_utf8_lossy(&out).contains("only")); +} + #[test] fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index bca3bc36e..b0ee84d35 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -693,6 +693,17 @@ pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names r.finish().map(|_| ()) } +/// The form's data changed outside its scripts (a data file imported, a picture chosen): lay +/// the form out again from the data, then, when `calculate`, run every calculation. +pub(crate) fn on_data_replaced(doc: &mut pdfcraft_cos::Document, tpl: &Template, calculate: bool, run: &mut XfaRun) -> Result<(), String> { + let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS); + r.relayout()?; + if calculate { + r.calculates()?; + } + r.finish().map(|_| ()) +} + /// A button's click script (or any event, by SOM path and activity), then calculations. pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<(), String> { let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index b3de8ca60..337ae7652 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -254,6 +254,11 @@ const MAX_LAYER_STATE: usize = 1024; /// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option { + // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): + // clicking it picks a picture, as `buttonImportIcon()` does. + if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { + return Some(af::ButtonAction::ImportIcon); + } let pick = |dict: &Dict| -> Option { dict.get(b"A").cloned().or_else(|| dict.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|aa| aa.get(b"U").cloned()))) }; diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 96149f2e4..87b2f833b 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -695,11 +695,11 @@ impl PdfCraftApp { Err(e) => app.notify_fmt("Couldn't read {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), }; match self.save_override.clone() { - Some(p) if [".xfdf", ".fdf", ".xml", ".csv", ".txt"].iter().any(|e| p.ends_with(e)) => import(self, p.into()), + Some(p) if [".xfdf", ".fdf", ".xml", ".xdp", ".csv", ".txt"].iter().any(|e| p.ends_with(e)) => import(self, p.into()), Some(_) => {} None => { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("Comment and form data").to_string(), &["xfdf", "fdf", "xml", "csv", "txt"]) + .add_filter(tl!("Comment and form data").to_string(), &["xfdf", "fdf", "xml", "xdp", "csv", "txt"]) .set_title(tl!("Import data").to_string()); let target = self.active_ids().map(|(_, id)| id); self.ask_one(crate::pickers::Ask::File(dialog), target, import); @@ -820,6 +820,7 @@ impl PdfCraftApp { let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); #[cfg(not(target_arch = "wasm32"))] { + let xfa = doc.info.xfa.is_some(); let write = move |app: &mut Self, path: std::path::PathBuf| { // Include what was typed while the save panel was open (#166). if !app.commit_typing_in(id) { @@ -842,6 +843,15 @@ impl PdfCraftApp { let d = rfd::AsyncFileDialog::new().set_title(title).set_file_name(format!("{stem}.xfdf")); let d = if comments { d.add_filter("XFDF", &["xfdf"]).add_filter("FDF", &["fdf"]) + } else if xfa { + // An XFA form's data, as Acrobat exports it, first. + d.set_file_name(format!("{stem}.xml")) + .add_filter("XML", &["xml"]) + .add_filter("XDP", &["xdp"]) + .add_filter("XFDF", &["xfdf"]) + .add_filter("FDF", &["fdf"]) + .add_filter("CSV", &["csv"]) + .add_filter(tl!("Text").to_string(), &["txt"]) } else { d.add_filter("XFDF", &["xfdf"]) .add_filter("FDF", &["fdf"]) diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 3008e3dea..be56fb4d4 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -83,8 +83,7 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for ## Deliberately not done (yet) - **Data binding** is the default one only: explicit `bind ref` expressions, global binding and - data descriptions are not followed, and no standalone XML or XDP data file is imported or - exported. + data descriptions are not followed (a data description is kept as written, not checked). - **Scripting.** JavaScript (boa) and FormCalc (a native interpreter, `pdfcraft_js::formcalc`) share one object model, one set of effects and one set of budgets. FormCalc covers the language and the common built-ins (arithmetic, logical, string, date/time, financial, unit); @@ -119,7 +118,16 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for values of `` as the options; the data holds saved values, several on separate lines. Password fields are password text fields. Signature fields are `/Sig` fields the app can sign. Image fields show the picture the data or template holds (JPEG, PNG, GIF) - but a new picture can't be chosen; barcode fields show their value as text, not as bars. + and are buttons that pick a new one, kept in the data as base64 with its `xfa:contentType` + (`write_data_image`); barcode fields show their value as text, not as bars. +- **Data files.** `export_data` writes the data as Acrobat's Export Data does: an XML document + holding the data alone (the form's root data node at the root, its namespaces declared) or + an XDP holding the whole datasets and a reference to the PDF. `import_data` takes an XDP, a + datasets or data element, or a data document whose root is the form's root data node, and + makes it the form's whole data, keeping the rest of the datasets (data descriptions). The + file's data is held as a tree and written with the packet's own namespace prefix, never as + the file's markup, and every rewrite of the packet is parsed back before it is written; the + engine then lays a dynamic form out again from it and runs its calculations. - Missing: `keep` constraints, `subformSet` relations, `rl-tb` is mirrored `lr-tb`, font metrics are the approximate Helvetica ones, and line heights are 1.15 × size. - Acrobat cannot be an oracle here (clean-room rules): layout follows the specification, and diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 9c0f51dd1..504e4b367 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -454,6 +454,10 @@ enum Op<'a> { Values(&'a [Want]), /// Take the node at this path out. Remove(&'a DataPath), + /// Make these nodes the whole content of the `xfa:data` element (an import). + ReplaceData(&'a [ImportNode]), + /// Make the leaf at this path a picture: base64 text with its content type. + Picture(&'a DataPath, &'a str, &'a str), } /// Note that a value could not be written (once per kind of reason, naming a few fields). @@ -582,10 +586,133 @@ const WRAP_CLOSE: &str = ""; /// change: values go into the data nodes they bind to, missing nodes are added, and everything /// else (data no field binds to, other namespaces, attributes, comments, layout) stays as /// written. +/// The prefix bound to the XFA data namespace where `n` is (`None`: unbound or the default +/// namespace, which attributes can't use). +fn xfa_prefix(n: roxmltree::Node) -> Option { + n.namespaces().find(|ns| ns.uri() == XFA_DATA_NS).and_then(|ns| ns.name()).filter(|p| !p.is_empty()).map(str::to_string) +} + +/// Data read from a file, held as a tree so it can be written into any packet with the +/// prefix that packet uses, never as the file's own markup. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ImportNode { + pub name: String, + /// The element's namespace (`None`: none). + pub ns: Option, + /// `(namespace, name, value)`. + pub attrs: Vec<(Option, String, String)>, + pub text: String, + pub children: Vec, +} + +/// Deepest data an import keeps. +const MAX_IMPORT_DEPTH: usize = MAX_DEPTH; + +/// `n` as an [`ImportNode`], counting nodes against `budget`; `None` past the depth or node +/// limits. +fn import_node(n: roxmltree::Node, depth: usize, budget: &mut usize) -> Option { + if depth > MAX_IMPORT_DEPTH || *budget == 0 { + return None; + } + *budget -= 1; + let mut children = Vec::new(); + let mut text = String::new(); + for c in n.children() { + if c.is_element() { + children.push(import_node(c, depth + 1, budget)?); + } else if let Some(t) = c.text() { + text.push_str(t); + } + } + // A group's own text is layout whitespace. + if !children.is_empty() && text.trim().is_empty() { + text.clear(); + } + Some(ImportNode { + name: n.tag_name().name().to_string(), + ns: n.tag_name().namespace().map(str::to_string), + attrs: n + .attributes() + .filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/")) + .map(|a| (a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string())) + .collect(), + text, + children, + }) +} + +/// `nodes` as markup for a packet whose XFA data namespace prefix is `xfa` (declared where +/// they go); other namespaces are declared on the elements and attributes that use them. +fn serialize_nodes(nodes: &[ImportNode], xfa: &str) -> String { + let mut out = String::new(); + for n in nodes { + serialize_node(n, xfa, &mut out, 0); + } + out +} + +fn serialize_node(n: &ImportNode, xfa: &str, out: &mut String, depth: usize) { + if depth > MAX_IMPORT_DEPTH { + return; + } + let mut decls: Vec<(String, String)> = Vec::new(); + let mut qualify = |ns: &Option, name: &str, attr: bool| -> String { + match ns.as_deref() { + None => xml_name(name), + Some(uri) if uri == XFA_DATA_NS => format!("{xfa}:{}", xml_name(name)), + Some("http://www.w3.org/XML/1998/namespace") => format!("xml:{}", xml_name(name)), + Some(uri) => { + let p = match decls.iter().position(|(_, u)| u == uri) { + Some(i) => format!("n{i}"), + None => { + decls.push((format!("n{}", decls.len()), uri.to_string())); + format!("n{}", decls.len() - 1) + } + }; + let _ = attr; + format!("{p}:{}", xml_name(name)) + } + } + }; + let tag = qualify(&n.ns, &n.name, false); + let attrs: Vec = n + .attrs + .iter() + .take(256) + .map(|(ns, name, value)| { + let q = qualify(ns, name, true); + format!(" {q}=\"{}\"", escape(value).0) + }) + .collect(); + out.push('<'); + out.push_str(&tag); + for (p, uri) in &decls { + out.push_str(&format!(" xmlns:{p}=\"{}\"", escape(uri).0)); + } + for a in &attrs { + out.push_str(a); + } + if n.text.is_empty() && n.children.is_empty() { + out.push_str("/>"); + return; + } + out.push('>'); + out.push_str(&escape(&n.text).0); + for c in &n.children { + serialize_node(c, xfa, out, depth + 1); + } + out.push_str(&format!("")); +} + fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merged { let wants: &[Want] = match op { Op::Values(w) => w, - Op::Remove(_) => &[], + Op::Remove(_) | Op::ReplaceData(_) | Op::Picture(..) => &[], + }; + // Fresh data, where none exists yet (declared with the `xfa` prefix). + let fresh_children = |warnings: &mut Vec| match op { + Op::ReplaceData(nodes) => serialize_nodes(nodes, "xfa"), + _ => build_children(wants, warnings), }; if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { warnings.push("The XFA data nests XML elements deeper than the reader accepts; the field values were not written to it".into()); @@ -616,10 +743,11 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merg let mut edits = Vec::new(); let mut data = None; match datasets { - None if matches!(op, Op::Remove(_)) => return Merged::Unchanged, + None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, None if within == Within::Xdp => { let root = parsed.root_element(); - match append_edit(src, root, &fresh_datasets(&build_children(wants, warnings))) { + let children = fresh_children(warnings); + match append_edit(src, root, &fresh_datasets(&children)) { Some(e) => edits.push(e), None => { warnings.push("The XFA packets could not be extended with data; the field values were not written".into()); @@ -633,10 +761,11 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merg } Some(ds) => match ds.children().find(|n| is(n, "data")) { Some(d) => data = Some(d), - None if matches!(op, Op::Remove(_)) => return Merged::Unchanged, + None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, None => { let prefix = qname(src, &ds.range()).and_then(|q| q.split_once(':')).map(|(p, _)| format!("{p}:")).unwrap_or_default(); - let element = format!("<{prefix}data>{}", build_children(wants, warnings)); + let children = fresh_children(warnings); + let element = format!("<{prefix}data>{}", children); match append_edit(src, ds, &element) { Some(e) => edits.push(e), None => { @@ -650,6 +779,52 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merg if let Some(data) = data { match op { Op::Values(w) => merge_values(src, data, w, &mut edits, warnings), + Op::ReplaceData(nodes) => { + // Everything between the start and end tags; `` opens up. Written + // with the prefix the packet binds to the data namespace. + let prefix = xfa_prefix(data); + let markup = serialize_nodes(nodes, prefix.as_deref().unwrap_or("xfa")); + let range = data.range(); + match (start_tag_end(src, &range), qname(src, &range)) { + (Some(tag_end), Some(name)) if tag_end + 1 == range.end && src.get(..tag_end).is_some_and(|s| s.ends_with('/')) => { + edits.push(Edit { start: range.start, end: range.end, text: format!("<{name}>{markup}") }); + } + (Some(tag_end), _) => { + let close = src.get(range.clone()).and_then(|s| s.rfind(" edits.push(Edit { start: tag_end + 1, end: close, text: markup }), + _ => { + warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); + return Merged::Failed; + } + } + } + _ => { + warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); + return Merged::Failed; + } + } + } + Op::Picture(path, content_type, base64) => { + let mut node = Some(data); + for (name, idx) in path.iter() { + let want = xml_name(name); + node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == want).nth(*idx)); + } + let Some(n) = node else { return Merged::Unchanged }; + if n.children().any(|c| c.is_element()) { + // A data group, not a leaf: a picture would wipe what's under it. + skipped(warnings, STRUCTURED, &path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".")); + return Merged::Failed; + } + let name = qname(src, &n.range()).unwrap_or("value").to_string(); + let (ct, _) = escape(content_type); + let attr = match xfa_prefix(n) { + Some(p) => format!("{p}:contentType=\"{ct}\""), + None => format!("xmlns:xfa=\"{XFA_DATA_NS}\" xfa:contentType=\"{ct}\""), + }; + edits.push(Edit { start: n.range().start, end: n.range().end, text: format!("<{name} {attr}>{base64}") }); + } Op::Remove(path) => { // The element at `path`, with the whitespace before it. let mut node = Some(data); @@ -933,6 +1108,219 @@ pub fn remove_data_instance(doc: &mut Document, path: &DataPath) -> Result, +) -> Result { + use base64::Engine; + if path.is_empty() { + return Err(XfaError::Malformed("a picture needs a data path".into())); + } + if bytes.len() > MAX_DATA_IMAGE { + return Err(XfaError::TooLarge(format!("the picture is larger than {} MB", MAX_DATA_IMAGE >> 20))); + } + let ct: String = content_type.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '.' | '+')).take(64).collect(); + let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + let ensure = [Want { som: String::new(), path: path.clone(), text: String::new(), ensure: true }]; + rewrite(doc, &[Op::Values(&ensure), Op::Picture(path, &ct, &b64)], reuse) +} + +/// What an import of a data file did. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DataImport { + /// Element nodes the new data holds. + pub nodes: usize, + pub warnings: Vec, + /// The datasets stream written. + pub stream: Option, +} + +/// The data a file holds, as the children of `xfa:data`: an XDP or datasets document (Export +/// Data's output, Designer's), a bare data element, or a data document whose root is the +/// form's root data node (named `expect_root`, when the caller knows it). Anything else is +/// refused: a form's data is never replaced by what is not its data. +pub fn parse_data_file(text: &str, expect_root: Option<&str>) -> Result, XfaError> { + if text.len() > MAX_IMPORT { + return Err(XfaError::TooLarge(format!("the data file is larger than {} MB", MAX_IMPORT >> 20))); + } + let text = text.trim_start_matches('\u{feff}'); + if crate::xml_nesting_depth(text) > MAX_IMPORT_DEPTH { + return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); + } + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let parsed = roxmltree::Document::parse_with_options(text, opts).map_err(|e| XfaError::Malformed(format!("the data file is not XML: {e}")))?; + let root = parsed.root_element(); + let local = root.tag_name().name(); + let in_xfa = |n: &roxmltree::Node| n.tag_name().namespace().is_none_or(|ns| ns == XFA_DATA_NS); + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name && in_xfa(n); + let data = match local { + "xdp" => { + let ds = root.children().find(|n| is(n, "datasets")).ok_or_else(|| XfaError::Malformed("the XDP holds no datasets".into()))?; + ds.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? + } + "datasets" if in_xfa(&root) => { + root.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? + } + "data" if in_xfa(&root) => root, + _ => { + if root.tag_name().namespace().is_some() { + return Err(XfaError::Malformed(format!("<{local}> is not XFA form data"))); + } + if let Some(want) = expect_root + && local != want + { + return Err(XfaError::Malformed(format!("the data file's root is <{local}>; this form's data root is <{want}>"))); + } + let mut budget = MAX_NODES as usize; + let node = import_node(root, 0, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; + return Ok(vec![node]); + } + }; + let mut budget = MAX_NODES as usize; + let mut out = Vec::new(); + for c in data.children().filter(|c| c.is_element()) { + out.push(import_node(c, 1, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?); + } + Ok(out) +} + +/// Replace the form's data with what the file `text` holds (see [`parse_data_file`]), keeping +/// the rest of the datasets (data descriptions, say). `reuse`: the stream this edit already +/// wrote. +pub fn import_data(doc: &mut Document, text: &str, expect_root: Option<&str>, reuse: Option) -> Result { + let nodes = parse_data_file(text, expect_root)?; + fn count(n: &[ImportNode]) -> usize { + n.iter().map(|c| 1 + count(&c.children)).sum() + } + let r = rewrite(doc, &[Op::ReplaceData(&nodes)], reuse)?; + if !r.written { + return Err(XfaError::Malformed(r.warnings.first().cloned().unwrap_or_else(|| "the data could not be written".into()))); + } + Ok(DataImport { nodes: count(&nodes), warnings: r.warnings, stream: r.stream }) +} + +/// The datasets packet's text: from the XFA array's `datasets` item, or the whole XDP. +fn datasets_packet(doc: &Document) -> Result, XfaError> { + let Some(root) = doc.root() else { return Ok(None) }; + let catalog = doc.get(root); + let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Ok(None) }; + let Some(xfa) = acro.as_dict().and_then(|a| a.get(b"XFA")).map(|x| doc.resolve(x)) else { return Ok(None) }; + let text_of = |o: &Object| -> Result, XfaError> { + match &*doc.resolve(o) { + Object::Stream(s) => Ok(crate::packets::decode(&s.decoded_within(64 << 20)?).map(|(t, _)| t)), + _ => Ok(None), + } + }; + match &*xfa { + Object::Array(items) => { + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); + match at.and_then(|i| items.get(i + 1)) { + Some(o) => text_of(o), + None => Ok(None), + } + } + Object::Stream(_) => text_of(&xfa), + _ => Ok(None), + } +} + +/// The data to export, as Acrobat's Export Data writes it: `xdp` false gives an XML document +/// holding the data alone (the form's root data node at the root, or the data element when +/// there are several); true gives an XDP holding the whole datasets and a reference to the +/// PDF, named `pdf_name`. A document whose packets can't be read is an error, not empty data. +pub fn export_data(doc: &Document, xdp: bool, pdf_name: &str) -> Result { + if crate::read_packets(doc)?.is_none() { + return Err(XfaError::NotXfa); + } + let packet = datasets_packet(doc)?; + let (datasets, data) = match &packet { + Some(text) => match datasets_markup(text) { + Ok(Some(m)) => m, + Ok(None) => (fresh_datasets(""), String::new()), + Err(()) => return Err(XfaError::Malformed("the XFA data could not be read".into())), + }, + None => (fresh_datasets(""), String::new()), + }; + let mut out = String::from("\n"); + if xdp { + let (name, _) = escape(pdf_name); + out.push_str("\n"); + out.push_str(&datasets); + out.push_str(&format!("\n\n\n")); + } else { + out.push_str(if data.is_empty() { &datasets } else { &data }); + out.push('\n'); + } + Ok(out) +} + +/// The `xfa:datasets` element of `text` as written, and the data alone (its one child, or the +/// data element), each with the namespaces it uses declared on it. `Ok(None)`: no datasets +/// element; `Err`: the text can't be read. +fn datasets_markup(text: &str) -> Result, ()> { + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { + return Err(()); + } + let wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); + let (src, parsed) = match roxmltree::Document::parse_with_options(text, opts) { + Ok(d) => (text, d), + Err(_) => (wrapped.as_str(), roxmltree::Document::parse_with_options(&wrapped, opts).map_err(|_| ())?), + }; + let Some(ds) = parsed.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets") else { return Ok(None) }; + let data = ds.children().find(|n| n.is_element() && n.tag_name().name() == "data"); + let data_alone = match data { + Some(d) => { + let kids: Vec = d.children().filter(|c| c.is_element()).collect(); + match kids.as_slice() { + [one] => element_markup(src, *one).ok_or(())?, + _ => element_markup(src, d).ok_or(())?, + } + } + None => String::new(), + }; + Ok(Some((element_markup(src, ds).ok_or(())?, data_alone))) +} + +/// `n` as written in `src`, with the namespace declarations in scope that its start tag lacks +/// added to it, so it stands alone. +fn element_markup(src: &str, n: roxmltree::Node) -> Option { + let range = n.range(); + let text = src.get(range.clone())?; + let tag_end = start_tag_end(src, &range)?; + let start_tag = src.get(range.start..tag_end)?; + let mut decls = String::new(); + for ns in n.namespaces() { + let attr = match ns.name() { + Some(p) => format!("xmlns:{p}="), + None => "xmlns=".to_string(), + }; + if !start_tag.contains(&attr) { + decls.push_str(&format!(" {attr}\"{}\"", escape(ns.uri()).0)); + } + } + if decls.is_empty() { + return Some(text.to_string()); + } + let name_end = qname(src, &range)?.len() + 1; + Some(format!("{}{decls}{}", text.get(..name_end)?, text.get(name_end..)?)) +} + /// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script /// event changed becomes a single new datasets stream. When the packet is already the stream /// `reuse` (written earlier for the same event), that stream is replaced in place instead of @@ -1011,6 +1399,25 @@ fn merge_all(text: &str, within: Within, ops: &[Op], warnings: &mut Vec) current } +/// Longest packet text written (the packets together are read up to 64 MB). +const MAX_PACKET_WRITE: usize = 48 << 20; + +/// Is `text` a packet the reader will take back (well-formed, within size)? A rewrite that +/// isn't is not written: better the old data than an unreadable packet. +fn readable(text: &str, within: Within, warnings: &mut Vec) -> bool { + if text.len() > MAX_PACKET_WRITE { + warnings.push(format!("The XFA data would be larger than {} MB; it was not written", MAX_PACKET_WRITE >> 20)); + return false; + } + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let ok = roxmltree::Document::parse_with_options(text, opts).is_ok() + || (within == Within::Packet && roxmltree::Document::parse_with_options(&format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"), opts).is_ok()); + if !ok { + warnings.push("The XFA data would not be well-formed XML after this change; it was not written".into()); + } + ok +} + fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option) -> Result { let mut report = DatasetsWrite::default(); if ops.is_empty() { @@ -1034,6 +1441,9 @@ fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option) -> Result) -> Result) -> Result { let Some((text, encoding)) = packet_text(doc, &xfa, warnings) else { return Ok(report) }; let Some(new) = merge_all(&text, Within::Xdp, ops, warnings) else { return Ok(report) }; + if !readable(&new, Within::Xdp, warnings) { + return Ok(report); + } let r = put_stream(doc, Some(&xfa), reuse, &encode(&new, encoding)); report.stream = Some(r); acro.set(b"XFA".to_vec(), Object::Ref(r)); @@ -1091,7 +1507,8 @@ fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> V let mut out = Vec::new(); for f in fields { let path = path_of(tpl, &som_of(doc, f)); - let Some(text) = data.text_at(&path) else { continue }; + let Some(text) = data.value_at(&path) else { continue }; + let text = text.as_str(); let value = match &f.data { FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) { Some(pat) => iso_to_pattern(text, &pat), diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index ee8af4cf1..4d6f0911e 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -60,6 +60,8 @@ pub enum WidgetKind { }, /// A signature field, to be signed in the app. Signature, + /// An image field: a push button that picks a picture (the picture itself is page content). + Image, /// A date field with its Acrobat format pattern (`yyyy-mm-dd`). Date(String), CheckBox { @@ -726,6 +728,7 @@ impl Layouter<'_> { } // Data adds instances of a repeating subform (rows added in another viewer). let instances = self.instances(node, &ctx.som); + self.warn_truncated(node, &ctx.som, instances); let ctx_parent = ctx; for i in 0..instances { let ctx = ctx_parent.with_som(&sf.common, sib + i); @@ -791,6 +794,7 @@ impl Layouter<'_> { { // Rows added as data (in another viewer) repeat the row. let instances = self.instances(child, &ctx.som); + self.warn_truncated(child, &ctx.som, instances); for inst in 0..instances { let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); if self.make_room(h)? @@ -837,6 +841,22 @@ impl Layouter<'_> { instance_count(&sf.occur, from_data) } + /// The data holds more instances of `node` than `laid_out`: say so, since calculations + /// then see only those. + fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) { + let Node::Subform(sf) = node else { return }; + let from_data = match (&sf.common.name, self.data) { + (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), + _ => 0, + }; + if from_data > laid_out { + self.warn_once(format!( + "{}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those)", + sf.common.name.as_deref().unwrap_or("a subform") + )); + } + } + fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { let c = node.common(); let spent = self.measures.get().saturating_add(1); @@ -1499,8 +1519,7 @@ impl Layouter<'_> { (None, Value::Image { content_type, data }) => self.draw_image(data, content_type, box_)?, _ => {} } - self.warn_once("image fields show their picture; a new picture can't be chosen yet"); - return Ok(()); + // Then the field itself, which clicking gives a new picture. } Ui::Barcode => { // The value as text, where the bars would be. @@ -1616,6 +1635,10 @@ impl Layouter<'_> { (kind, ui, widget_border(f.ui_border.as_ref(), false), value, f.tooltip.clone()) } Ui::Signature => (WidgetKind::Signature, ui, widget_border(f.ui_border.as_ref(), false), None, f.tooltip.clone()), + Ui::ImageEdit => { + let none = WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }; + (WidgetKind::Image, ui, none, None, f.tooltip.clone()) + } Ui::PasswordEdit => (WidgetKind::Password, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()), _ => (WidgetKind::Text, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()), }; diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index f9fd29d3b..8dc5d20e4 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -89,6 +89,7 @@ pub(crate) fn xml_nesting_depth(text: &str) -> usize { max } +pub use data::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image}; pub use data::{ DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, data_path, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index e3413112f..54e70a35b 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -156,6 +156,10 @@ pub struct Written { pub warnings: Vec, } +/// On a push button generated for an XFA image field: clicking it picks a picture (the forms +/// layer reads this key). +pub const IMAGE_FIELD_KEY: &[u8] = b"PCXfaImage"; + /// On an image XObject written for a picture: the SHA-256 (hex) of the picture's bytes. A /// re-layout (after every script event) finds the XObjects on the pages it replaces by this, /// and reuses them instead of decoding and embedding the same picture again. Nothing is kept @@ -370,6 +374,13 @@ impl Emitter<'_> { ff |= 1; } } + WidgetKind::Image => { + // A push button with nothing drawn: the forms layer treats one so marked as an + // image field (clicking picks a picture), with no action other viewers run. + d.set(b"FT".to_vec(), Object::name("Btn")); + ff |= 1 << 16; + d.set(IMAGE_FIELD_KEY.to_vec(), Object::Bool(true)); + } WidgetKind::CheckBox { on, .. } => { d.set(b"FT".to_vec(), Object::name("Btn")); let on = state_name(on); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 5f8919915..4681ba01e 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -830,7 +830,8 @@ fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { assert!(matches!(&other.kind, WidgetKind::Choice { list_box: false, multi: false, editable: true, .. })); assert_eq!(w.iter().find(|w| w.name == "pin").unwrap().kind, WidgetKind::Password); assert_eq!(w.iter().find(|w| w.name == "sign").unwrap().kind, WidgetKind::Signature); - assert!(w.iter().all(|w| w.name != "photo" && w.name != "code"), "image and barcode fields make no widget"); + assert_eq!(w.iter().find(|w| w.name == "photo").unwrap().kind, WidgetKind::Image, "an image field is a button that picks a picture"); + assert!(w.iter().all(|w| w.name != "code"), "barcode fields make no widget"); // The PNG in the image field and the GIF in the draw are pictures, fitted to their boxes. let images: Vec<(&Rect, usize)> = p1.items.iter().filter_map(|i| if let Item::Image { rect, data, .. } = i { Some((rect, data.len())) } else { None }).collect(); @@ -839,13 +840,14 @@ fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { assert_eq!(images[1].1, TINY_GIF.len()); let t = texts(p1); assert!(t.iter().any(|s| s == "12345"), "the barcode's value as text: {t:?}"); - assert!(form.warnings.iter().any(|w| w.contains("barcode")) && form.warnings.iter().any(|w| w.contains("image fields")), "{:?}", form.warnings); + assert!(form.warnings.iter().any(|w| w.contains("barcode")), "{:?}", form.warnings); + assert!(!form.warnings.iter().any(|w| w.contains("can't be chosen")), "pictures can be chosen now: {:?}", form.warnings); // Written into the PDF: /Ch fields with their options and flags, a /Sig field, a password // field, and Flate images (the PNG with a soft mask). let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); let report = render_into(&mut doc).unwrap(); - assert_eq!(report.fields, 6, "country, langs, other, pin, sign, summary"); + assert_eq!(report.fields, 7, "country, langs, other, pin, sign, photo, summary"); let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); let doc = Document::open(Arc::new(bytes)).unwrap(); let fields = pdfcraft_forms_free_fields(&doc); @@ -956,7 +958,7 @@ fn pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocate .replace("", "AAAA"); let mut doc = Document::open(Arc::new(shell(&xml))).unwrap(); let report = render_into(&mut doc).unwrap(); - assert_eq!(report.fields, 6); + assert_eq!(report.fields, 7); assert!(report.warnings.iter().any(|w| w.contains("image")), "{:?}", report.warnings); } @@ -1570,3 +1572,219 @@ fn a_carriage_return_and_line_feed_are_one_line_break() { let lf = form.pages.iter().flat_map(widgets).find(|w| w.name == "notes").unwrap().rect.h; assert!((crlf - lf).abs() < 0.01); } + +#[test] +fn data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them() { + use crate::data::{export_data, import_data}; + let data = "
JPEnglish\nSpanish
"; + let mut doc = Document::open(Arc::new(shell(&fields_template(data)))).unwrap(); + render_into(&mut doc).unwrap(); + // XML: the data alone, rooted at the form's root data node, as Acrobat's XML export is. + let xml = export_data(&doc, false, "fields.pdf").unwrap(); + assert!(xml.starts_with("\nJP") && xml.contains("English\nSpanish"), "{xml}"); + assert!(xml.trim_end().ends_with(""), "{xml}"); + assert_eq!(crate::parse_data_file(&xml, Some("form")).unwrap().len(), 1, "the export is importable"); + // XDP: the datasets inside an xdp envelope with a reference to the PDF. + let xdp = export_data(&doc, true, "my \"form\".pdf").unwrap(); + assert!(xdp.contains("") && xdp.contains(""), "{xdp}"); + assert!(xdp.contains(""), "{xdp}"); + assert_eq!(parse_datasets(&xdp).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("JP")); + // A form without a datasets packet exports empty data. + let bare = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); + let xml = export_data(&bare, false, "x.pdf").unwrap(); + assert!(xml.contains("") || xml.contains(""), "{xml}"); + // A datasets packet alone (no template) still exports its data; no XFA at all is an error. + let static_doc = Document::open(Arc::new(static_shell("Ada"))).unwrap(); + assert!(export_data(&static_doc, false, "s.pdf").unwrap().contains("Ada")); + assert!(export_data(&static_doc, true, "s.pdf").unwrap().contains(""))).unwrap(); + assert!(export_data(&plain, false, "p.pdf").unwrap().contains("", + "
", + ); + let shapes = [ + format!( + "\nCA" + ), + format!("
CA
"), + format!("\u{feff}
CA
"), + "
CA
".to_string(), + ]; + for file in &shapes { + let mut doc = Document::open(Arc::new(shell(&with_desc))).unwrap(); + render_into(&mut doc).unwrap(); + let r = import_data(&mut doc, file, Some("form"), None).unwrap(); + assert_eq!(r.nodes, 3, "{file}"); + let d = crate::data_of(&doc).unwrap(); + assert_eq!(d.text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA"), "{file}"); + assert_eq!(d.text_at(&som_to_path("form[0].page1[0].langs[0]")), None, "the old data is gone: {file}"); + let xdp = export_data(&doc, true, "x.pdf").unwrap(); + assert!(xdp.contains("dataDescription"), "kept: {xdp}"); + assert!(!export_data(&doc, false, "x.pdf").unwrap().contains("dataDescription"), "the XML export is the data alone"); + // Laid out again, the form shows the imported value. + let (t, _) = parse(&with_desc).unwrap(); + rerender(&mut doc, &t).unwrap(); + let fields = pdfcraft_forms_free_fields(&doc); + let c = &fields.iter().find(|(n, _)| n == "country").unwrap().1; + assert_eq!(c.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("CA"), "{file}"); + } + // Not XFA data: refused, the document untouched. + let mut doc = Document::open(Arc::new(shell(&fields_template(data)))).unwrap(); + render_into(&mut doc).unwrap(); + for bad in [ + "", + "1", + "not xml at all", + "", + ] { + assert!(import_data(&mut doc, bad, Some("form"), None).is_err(), "{bad}"); + } + assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("JP")); + // Empty data: the data element is emptied, not removed. + let r = import_data(&mut doc, &format!(""), Some("form"), None).unwrap(); + assert_eq!(r.nodes, 0); + assert!(crate::data_of(&doc).is_some()); + assert_eq!(crate::data_of(&doc).unwrap().children.len(), 0); + // Hostile: a file over the size cap, and one with more nodes than the parser allows. + assert!(import_data(&mut doc, &"x".repeat(17 << 20), None, None).is_err()); + let many = format!("
{}
", "".repeat(1_100_000)); + assert!(import_data(&mut doc, &many, None, None).is_err()); + // A self-closing data element opens up for an import. + let empty = fields_template("") + .replace("", ""); + let mut doc = Document::open(Arc::new(shell(&empty))).unwrap(); + render_into(&mut doc).unwrap(); + import_data(&mut doc, "
FR
", Some("form"), None).unwrap(); + assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("FR")); +} + +const XFA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/"; + +#[test] +fn a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type() { + use crate::data::write_data_image; + let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); + render_into(&mut doc).unwrap(); + let path = som_to_path("form[0].page1[0].photo[0]"); + // No data node yet: it is made. + let r = write_data_image(&mut doc, &path, "image/gif", TINY_GIF, None).unwrap(); + assert!(r.written, "{:?}", r.warnings); + let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp; + assert!(xdp.contains("R0lGODlh"), "{xdp}"); + // Written again: replaced, not duplicated; a hostile content type is cleaned. + let r = write_data_image(&mut doc, &path, "image/png\">iVBORw0KGgo"), "{xdp}"); + assert!(!xdp.contains("")); + // The form shows it: laid out again, the page's picture is the PNG from the data. + let (t, _) = parse(&fields_template("")).unwrap(); + rerender(&mut doc, &t).unwrap(); + let d = crate::data_of(&doc).unwrap(); + assert!(d.text_at(&path).unwrap().starts_with("iVBORw0KGgo")); + // Too big is refused before anything is written. + assert!(write_data_image(&mut doc, &path, "image/png", &vec![0u8; (8 << 20) + 1], None).is_err()); + assert!(write_data_image(&mut doc, &Vec::new(), "image/png", TINY_GIF, None).is_err()); + // The image field is a push button marked as one, with no action and nothing drawn. + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let fields = pdfcraft_forms_free_fields(&doc); + let photo = &fields.iter().find(|(n, _)| n == "photo").unwrap().1; + assert_eq!(photo.name(b"FT"), Some(b"Btn".as_slice())); + assert_eq!(photo.int(b"Ff"), Some(1 << 16)); + assert_eq!(photo.get(b"PCXfaImage"), Some(&Object::Bool(true))); + assert!(!photo.contains(b"A") && !photo.contains(b"MK")); +} + +#[test] +fn imported_data_is_written_with_the_packets_own_prefix_and_never_leaves_it_unreadable() { + use crate::data::{export_data, import_data, write_data_image}; + // A file binding the data namespace to another prefix, with an attribute in it. + let file = format!( + "
CA
" + ); + let mut doc = Document::open(Arc::new(shell(&fields_template("
JP
")))).unwrap(); + render_into(&mut doc).unwrap(); + import_data(&mut doc, &file, Some("form"), None).unwrap(); + let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp; + assert!(xdp.contains("
"), "{xdp}"); + assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA")); + // An xsi:nil declared on an envelope the file's data element doesn't carry. + let file = "1
"; + import_data(&mut doc, file, Some("form"), None).unwrap(); + let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp; + assert!(xdp.contains("xmlns:n0=\"http://www.w3.org/2001/XMLSchema-instance\" n0:nil=\"true\""), "{xdp}"); + assert!(crate::data_of(&doc).is_some(), "the packet still parses"); + // Our own export, from a packet using another prefix, imports back. + let other = fields_template("
7
") + .replace("xfa:datasets", "xd:datasets") + .replace("xfa:data>", "xd:data>") + .replace("xmlns:xfa=", "xmlns:xd="); + let mut doc2 = Document::open(Arc::new(shell(&other))).unwrap(); + render_into(&mut doc2).unwrap(); + let exported = export_data(&doc2, true, "o.pdf").unwrap(); + import_data(&mut doc, &exported, Some("form"), None).unwrap(); + assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].pin[0]")), Some("7")); + // A picture into that other-prefix packet uses its prefix. + let path = som_to_path("form[0].page1[0].photo[0]"); + write_data_image(&mut doc2, &path, "image/gif", TINY_GIF, None).unwrap(); + let xdp = crate::read_packets(&doc2).unwrap().unwrap().xdp; + assert!(xdp.contains(""), "{xdp}"); + assert!(crate::data_of(&doc2).is_some()); + // A picture where the data node is a group is refused, the group kept. + let grouped = fields_template("
keep me
"); + let mut doc3 = Document::open(Arc::new(shell(&grouped))).unwrap(); + render_into(&mut doc3).unwrap(); + let r = write_data_image(&mut doc3, &path, "image/gif", TINY_GIF, None).unwrap(); + assert!(!r.written && r.warnings.iter().any(|w| w.contains("structured")), "{r:?}"); + assert!(crate::read_packets(&doc3).unwrap().unwrap().xdp.contains("keep me")); + // A field name XML can't hold is written under the same cleaned name the values use. + let odd = fields_template("").replace("name=\"photo\"", "name=\"Foto_Straße\""); + let mut doc4 = Document::open(Arc::new(shell(&odd))).unwrap(); + render_into(&mut doc4).unwrap(); + let r = write_data_image(&mut doc4, &som_to_path("form[0].page1[0].Foto_Straße[0]"), "image/gif", TINY_GIF, None).unwrap(); + assert!(r.written, "{r:?}"); + let xdp = crate::read_packets(&doc4).unwrap().unwrap().xdp; + assert_eq!(xdp.matches("R0lG"), "{xdp}"); + // What is not this form's data is refused: an SVG, an XDP without datasets, an XDP whose + // config has a element before the datasets, a data document with another root. + let mut doc = Document::open(Arc::new(shell(&fields_template("
JP
")))).unwrap(); + render_into(&mut doc).unwrap(); + for bad in [ + "".to_string(), + "", + &format!( + "
{rows}
" + ), + ); + let form = layout_xml(&many).unwrap(); + assert!(form.warnings.iter().any(|w| w.contains("60 rows") && w.contains("first 50")), "{:?}", form.warnings); +} diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 4998e7543..beec3937e 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -41,6 +41,9 @@ pub enum Format { Xml, Csv, Txt, + /// An XFA form's data as an XML Data Package (the engine writes it; for a form without + /// XFA packets it is the XML format). + Xdp, } impl Format { @@ -49,6 +52,7 @@ impl Format { "xfdf" => Format::Xfdf, "fdf" => Format::Fdf, "xml" => Format::Xml, + "xdp" => Format::Xdp, "csv" => Format::Csv, "txt" | "tab" | "tsv" => Format::Txt, _ => return None, @@ -577,7 +581,7 @@ pub fn export_data(doc: &Document, format: Format) -> String { }) .collect(); match format { - Format::Xml => { + Format::Xml | Format::Xdp => { let mut out = String::from("\n\n"); for (k, v) in &fields { let tag = xml_name(k); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 50b16cd5e..2667459a2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4639,7 +4639,7 @@ status = "shipped" commands = ["form.import_data"] tools = ["doc_import_data"] evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] -notes = "XFDF, FDF; values go through formats and validation; read-only fields take data" +notes = "XFDF, FDF; values go through formats and validation; read-only fields take data; XFA forms also take XDP/XML data files (form.xfa-data)" [[feature]] id = "form.export-data" @@ -4651,7 +4651,7 @@ status = "shipped" commands = ["form.export_data"] tools = ["doc_export_data"] evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] -notes = "XFDF, FDF" +notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data)" [[feature]] id = "form.data-xml-csv-txt" @@ -5200,10 +5200,10 @@ area = "F" tier = "P2" milestone = "M12" status = "partial" -commands = ["file.open", "file.save"] -tools = ["doc_open", "form_fill", "doc_save"] -evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"] -notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Missing: importing or exporting a standalone XML/XDP data file, explicit bind refs, data descriptions" +commands = ["file.open", "file.save", "form.export_data", "form.import_data"] +tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"] +evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/automation.rs::xfa_data_files_export_and_import_through_tools"] +notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" [[feature]] id = "form.xfa-flatten" From 826941dcfc5be98b1488e971c07623fa7fc8310c Mon Sep 17 00:00:00 2001 From: Ufuk Efe Date: Mon, 12 Oct 2026 01:29:48 +0300 Subject: [PATCH 04/16] docs(contributors): add Ufuk Efe to people.toml (#1177) --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 50ed2d9e2..d167e9f8b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -103,3 +103,8 @@ sync_github_name = true # use the name on your public GitHub profile as displa [people.Losminek] sync_github_name = true + +[people.yeniufuklar-com] +real_name = "Ufuk Efe" +display_name = "Ufuk Efe" +sync_github_name = true From 931dfa0d7d886dda0dba0daa34c20408be71cb4d Mon Sep 17 00:00:00 2001 From: Joel Tony Date: Mon, 12 Oct 2026 04:00:00 +0530 Subject: [PATCH 05/16] Issue #1185: Protect refuses passwords that encryption would make empty or equal (#1186) Encryption derives its keys from a prepared password, not the typed one: AES-256 (R6) applies SASLprep, falls back to the password as given if SASLprep refuses it, and truncates the UTF-8 to 127 bytes; RC4/AES-128 (R2-R4) use PDFDocEncoding cut or padded to exactly 32 bytes, and the padding starts with "(". Protection::validate checked only the typed strings, so a permissions password of U+00AD wrote a file that opens with owner rights and no password, and an open password "A" with a permissions password U+FF21 (or RC4/AES-128 passwords "a"x31 and "a"x31 + "(") wrote a file whose open password lifts the restrictions. pdfcraft-crypt gains effective_password(algorithm, password), the bytes the keys are derived from (it and create share the private prepared_password helper), re-exported from pdfcraft-cos. validate refuses a password whose effective bytes are those of "", and an open and a permissions password whose effective bytes are equal. Opening files is unchanged. Fixes #1185. --- crates/automation/tests/automation.rs | 19 +++++++++ crates/cos/src/lib.rs | 4 +- crates/crypt/src/lib.rs | 33 +++++++++++++-- crates/crypt/src/tests.rs | 47 +++++++++++++++++++++ crates/engine/src/lib.rs | 13 ++++++ crates/engine/src/tests.rs | 61 +++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 5 deletions(-) diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 0298b396b..0e5da853f 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1313,6 +1313,25 @@ fn protecting_with_an_open_password_alone_restricts_nothing() { } } +/// AES-256 keys on the SASLprep form of a password, which drops soft hyphens: a password made +/// only of them is no password at all (as the permissions password, it would give everyone owner +/// rights), so it is refused rather than written. +#[test] +fn protecting_with_a_password_encryption_ignores_is_refused() { + let dir = workdir("protect-ignored"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let answers: Vec = ["open_password", "permissions_password"] + .into_iter() + .map(|key| match a.call("doc_protect", &json!({ "doc": doc, key: "\u{AD}" })) { + Err(ToolError::Failed(m)) => m, + other => format!("{key}: {other:?}"), + }) + .collect(); + assert!(answers.iter().all(|m| m.contains("soft hyphens")), "{answers:?}"); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["security"]["protected"], false, "a refused call changes nothing"); +} + #[test] fn forms_through_tools() { let dir = workdir("forms"); diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index b6fe827e4..bb855cc0c 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -25,7 +25,9 @@ pub use bytes::Bytes; pub use document::{Document, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; pub use parser::{Lexer, parse_indirect}; -pub use pdfcraft_crypt::{Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, pdfdoc_byte, pdfdoc_char}; +pub use pdfcraft_crypt::{ + Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char, +}; pub use writer::{SaveOptions, pdf_date, serialize, write_full, write_incremental}; #[derive(Debug, thiserror::Error, Clone, PartialEq)] diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index b93fd8255..fd600cb9a 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -539,6 +539,32 @@ pub enum Algorithm { Aes256, } +/// A password as [`create`] prepares it: for R2–R4 its PDFDocEncoding (characters it lacks +/// dropped), at most 32 bytes; for R6 its SASLprep (RFC 4013: soft hyphens and the like removed, +/// other spaces made plain, then NFKC; the password as given if SASLprep refuses it), in UTF-8, +/// at most 127 bytes. +fn prepared_password(algorithm: Algorithm, password: &str) -> Vec { + match algorithm { + Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => legacy_password(password), + Algorithm::Aes256 => sasl_password(password), + } +} + +/// The bytes new encryption with `algorithm` derives its keys from for `password`: for R2–R4 its +/// PDFDocEncoding cut or padded to exactly 32 bytes (Algorithm 2); for R6 its SASLprep (the +/// password as given if SASLprep refuses it), at most 127 bytes of UTF-8. Two passwords with the +/// same bytes are the same password to the file, and a password with the bytes of `""` opens it +/// without asking, so callers can refuse either before encrypting. Different bytes can still give +/// the same key (RC4-40 keeps 5 bytes of a hash). `create` still uses the user password for an +/// empty R2–R4 owner password. +pub fn effective_password(algorithm: Algorithm, password: &str) -> Vec { + let prepared = prepared_password(algorithm, password); + match algorithm { + Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => pad(&prepared).to_vec(), + Algorithm::Aes256 => prepared, + } +} + /// Parameters for new encryption. #[derive(Clone, Debug)] pub struct NewEncryption<'a> { @@ -573,6 +599,8 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result { (d.v, d.r, d.length_bits) = match params.algorithm { @@ -587,8 +615,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result Result (Result<(), EditError>, Option<[Option; 3]>) { + let (mut s, id) = session_with(1); + let result = s.apply(id, Edit::Protect(p.clone())); + let reopened = result.is_ok().then(|| { + let saved = s.save_bytes(id).unwrap(); + let auth = |pw: Option<&str>| pdfcraft_cos::Document::open_with_password(saved.clone(), pw).ok()?.security().map(|h| h.auth()); + [auth(None), auth(p.open_password.as_deref()), auth(p.permissions_password.as_deref())] + }); + (result, reopened) +} + +#[test] +fn passwords_that_encryption_would_make_empty_or_equal_are_refused() { + use pdfcraft_cos::Algorithm::{Aes128, Rc4_128}; + use pdfcraft_cos::Auth::{Owner, User}; + let empty = "passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens"; + let same = "the open password and the permissions password must be different (PDF readers treat these two as the same password)"; + // AES-256 keys on the SASLprep form of a password (soft hyphens dropped, then NFKC), RC4 and + // AES-128 on its first 32 bytes. Written as typed, these would make a file that opens + // without a password, or whose open password lifts the restrictions. + let (a31, a32) = ("a".repeat(31), "a".repeat(32)); + let (long1, long2) = (format!("{a32}1"), format!("{a32}2")); + // The 32-byte padding starts with "(": 31 characters and the same followed by "(" are one key. + let padded = format!("{a31}("); + let cases = [ + ("soft hyphen open password", protection(Some("\u{AD}"), None), empty), + ("soft hyphen permissions password", protection(None, Some("\u{AD}")), empty), + ("soft hyphen permissions password beside an open one", protection(Some("0"), Some("\u{AD}")), empty), + ("fullwidth A, then A", protection(Some("\u{FF21}"), Some("A")), same), + ("A, then fullwidth A", protection(Some("A"), Some("\u{FF21}")), same), + ("fi ligature, then fi", protection(Some("\u{FB01}"), Some("fi")), same), + ("fi, then fi ligature", protection(Some("fi"), Some("\u{FB01}")), same), + ("RC4-128, same first 32 bytes", Protection { algorithm: Rc4_128, ..protection(Some(&long1), Some(&long2)) }, same), + ("AES-128, same first 32 bytes", Protection { algorithm: Aes128, ..protection(Some(&long1), Some(&long2)) }, same), + ("RC4-128, same once padded", Protection { algorithm: Rc4_128, ..protection(Some(&a31), Some(&padded)) }, same), + ("AES-128, same once padded", Protection { algorithm: Aes128, ..protection(Some(&a31), Some(&padded)) }, same), + ]; + let got: Vec<_> = cases.iter().map(|(case, p, _)| (*case, protect_and_reopen(p))).collect(); + let want: Vec<_> = cases.iter().map(|(case, _, message)| (*case, (Err(EditError::Protection(message.to_string())), None))).collect(); + assert_eq!(got, want, "each is refused, so no file is written"); + // Passwords that are empty or equal as typed keep their messages. + let (mut s, id) = session_with(1); + assert_eq!(s.apply(id, Edit::Protect(protection(Some(""), None))), Err(EditError::Protection("passwords can't be empty".into()))); + assert_eq!( + s.apply(id, Edit::Protect(protection(Some("same"), Some("same")))), + Err(EditError::Protection("the open password and the permissions password must be different".into())) + ); + // Passwords that stay different are fine: only the permissions password gives owner rights. + let distinct = [ + protection(Some("A"), Some("\u{FF21}\u{FF22}")), + Protection { algorithm: Rc4_128, ..protection(Some(&format!("1{a32}")), Some(&format!("2{a32}"))) }, + Protection { algorithm: Aes128, ..protection(Some(&format!("1{a32}")), Some(&format!("2{a32}"))) }, + ]; + for p in &distinct { + assert_eq!(protect_and_reopen(p), (Ok(()), Some([None, Some(User), Some(Owner)])), "{:?}", p.algorithm); + } +} + /// A one-page form: a text field and a check box (with appearance states). fn form_fixture() -> Vec { let objs = [ From 3fa7a3a3e834de4f4fb16883283dbcc6d5a505fe Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:30:13 +0900 Subject: [PATCH 06/16] M14: keep structural page order through inspection (#1182) * M14: keep structural page IDs in traversal order * M14: reuse structural page order during inspection --- crates/render/src/inspect.rs | 14 +++--- crates/render/src/structure.rs | 79 +++++++++++++++++++++++++++++----- 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 438932f99..1343c2b51 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -466,6 +466,7 @@ fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Resu struct Inspector<'a> { doc: &'a dyn Structure, + page_ids: Vec, page_index: HashMap, /// Named destinations (`/Names /Dests` tree), keyed by raw string bytes, built once. /// Looking each name up by walking the tree was O(links × names): minutes on manuals. @@ -474,8 +475,9 @@ struct Inspector<'a> { impl<'a> Inspector<'a> { fn new(doc: &'a dyn Structure) -> Self { - let page_index = doc.get_pages().into_iter().map(|(n, id)| (id, n as usize - 1)).collect(); - let mut me = Self { doc, page_index, named: HashMap::new() }; + let page_ids = doc.page_ids(); + let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect(); + let mut me = Self { doc, page_ids, page_index, named: HashMap::new() }; let mut named = HashMap::new(); if let Some(tree) = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) @@ -523,9 +525,7 @@ impl<'a> Inspector<'a> { } // Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them); // each is adopted through its topmost unlisted `/Parent`, so a split field stays one. - let mut pages: Vec<_> = self.page_index.iter().collect(); - pages.sort_by_key(|(_, i)| **i); - for (&pid, _) in pages { + for &pid in &self.page_ids { let Ok(page_dict) = self.doc.get_dictionary(pid) else { continue }; let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; for a in annots { @@ -1185,9 +1185,7 @@ impl<'a> Inspector<'a> { fn fonts(&self, out: &mut Vec) { let mut seen = HashSet::new(); - let mut pages: Vec<_> = self.page_index.iter().collect(); - pages.sort_by_key(|(_, i)| **i); - for (&pid, _) in pages { + for &pid in &self.page_ids { let Ok(fonts) = self.doc.get_page_fonts(pid) else { continue }; for (_, f) in fonts { let base = f diff --git a/crates/render/src/structure.rs b/crates/render/src/structure.rs index 018de5a6e..f6a0ad5d4 100644 --- a/crates/render/src/structure.rs +++ b/crates/render/src/structure.rs @@ -37,8 +37,8 @@ pub(crate) trait Structure { self.trailer().get(b"Root").and_then(Object::as_reference).and_then(|id| self.get_dictionary(id)) } - fn get_pages(&self) -> BTreeMap { - let mut pages = BTreeMap::new(); + fn page_ids(&self) -> Vec { + let mut pages = Vec::new(); let Ok(root) = self.catalog().and_then(|c| c.get(b"Pages")).and_then(Object::as_reference) else { return pages }; let mut pending = vec![(root, 0usize)]; let mut seen = HashSet::new(); @@ -49,9 +49,11 @@ pub(crate) trait Structure { let Ok(dict) = self.get_dictionary(id) else { continue }; match dict.get_type() { Ok(b"Page") => { - // Page numbers are 1-based u32s; a tree past u32::MAX pages stops there. - let Some(number) = u32::try_from(pages.len()).ok().and_then(|n| n.checked_add(1)) else { return pages }; - pages.insert(number, id); + // Keep the effective u32 page-count bound without trusting /Count. + if u32::try_from(pages.len()).ok().and_then(|n| n.checked_add(1)).is_none() { + return pages; + } + pages.push(id); } Ok(b"Pages") => { if let Ok(kids) = dict.get(b"Kids").and_then(|o| self.dereference(o)).and_then(|(_, o)| o.as_array()) { @@ -115,7 +117,7 @@ impl Structure for lopdf::Document { fn encrypted(&self) -> bool { self.is_encrypted() || self.was_encrypted() || self.trailer.has(b"Encrypt") } - // `get_pages` is not overridden: lopdf's own page-tree walk trusts /Count and pre-allocates + // `page_ids` is not overridden: lopdf's own page-tree walk trusts /Count and pre-allocates // by it, so a hostile /Count (a fuzz finding: 4294967295) aborted the process with a 48 GiB // allocation. The trait's bounded default walk counts the real pages instead. fn get_page_fonts(&self, page: ObjectId) -> Result, &Dictionary>> { @@ -226,8 +228,62 @@ trailer << /Size 4 /Root 1 0 R >> %%EOF"; let doc = lopdf::Document::load_mem(&pdf[..]).unwrap(); - let pages = Structure::get_pages(&doc); - assert_eq!(pages.len(), 1, "the walk counts the one real page, not /Count"); + let pages = Structure::page_ids(&doc); + assert_eq!(pages, [(3, 0)], "the walk counts the one real page, not /Count"); + } + + fn nested_sparse_page_tree(repeated_and_cyclic: bool) -> lopdf::Document { + let mut doc = lopdf::Document::with_version("1.7"); + let mut catalog = Dictionary::new(); + catalog.set("Type", Object::Name(b"Catalog".to_vec())); + catalog.set("Pages", Object::Reference((5, 0))); + doc.objects.insert((17, 0), Object::Dictionary(catalog)); + + let mut pages = Dictionary::new(); + pages.set("Type", Object::Name(b"Pages".to_vec())); + pages.set("Kids", vec![Object::Reference((31, 0)), Object::Reference((88, 0))]); + pages.set("Count", Object::Integer(if repeated_and_cyclic { 5 } else { 3 })); + doc.objects.insert((5, 0), Object::Dictionary(pages)); + + let mut nested = Dictionary::new(); + nested.set("Type", Object::Name(b"Pages".to_vec())); + nested.set("Parent", Object::Reference((5, 0))); + let kids = if repeated_and_cyclic { + vec![Object::Reference((12, 0)), Object::Reference((5, 0)), Object::Reference((31, 0)), Object::Reference((42, 0))] + } else { + vec![Object::Reference((12, 0)), Object::Reference((42, 0))] + }; + nested.set("Kids", kids); + nested.set("Count", Object::Integer(if repeated_and_cyclic { 4 } else { 2 })); + doc.objects.insert((88, 0), Object::Dictionary(nested)); + + for (id, parent) in [(31, 5), (12, 88), (42, 88)] { + let mut page = Dictionary::new(); + page.set("Type", Object::Name(b"Page".to_vec())); + page.set("Parent", Object::Reference((parent, 0))); + doc.objects.insert((id, 0), Object::Dictionary(page)); + } + doc.max_id = 88; + doc.trailer.set("Root", Object::Reference((17, 0))); + doc + } + + #[test] + fn page_walk_keeps_nested_sparse_order_and_first_visit_semantics() { + let doc = nested_sparse_page_tree(true); + assert_eq!(Structure::page_ids(&doc), [(31, 0), (12, 0), (42, 0)]); + } + + #[test] + fn lazy_and_compatibility_walks_keep_nested_sparse_order() { + let mut source = nested_sparse_page_tree(false); + let mut bytes = Vec::new(); + source.save_to(&mut bytes).unwrap(); + let expected = [(31, 0), (12, 0), (42, 0)]; + let eager = lopdf::Document::load_mem(&bytes).unwrap(); + let lazy = LazyStructure::new(Arc::new(bytes), None).unwrap(); + assert_eq!(Structure::page_ids(&eager), expected); + assert_eq!(Structure::page_ids(&lazy), expected); } #[test] @@ -246,10 +302,11 @@ trailer source.save_to(&mut bytes).unwrap(); let cos = Document::open(Arc::new(bytes)).unwrap(); let compressed = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap(); - let expected = lopdf::Document::load_mem(&compressed).unwrap().get_pages(); + let eager = lopdf::Document::load_mem(&compressed).unwrap(); + let expected = Structure::page_ids(&eager); let view = LazyStructure::new(Arc::new(compressed), None).unwrap(); - assert_eq!(view.get_pages().len(), 1); - assert_eq!(view.get_pages(), expected); + assert_eq!(view.page_ids().len(), 1); + assert_eq!(view.page_ids(), expected); assert!(!view.failed()); assert!( view.objects.iter().filter(|(_, slot)| slot.get().is_some()).count() <= 3, From 75b113ac5be20cfd6bfa96d69d93d6f7708d7649 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:30:25 +0200 Subject: [PATCH 07/16] Raise workspace rust-version to 1.95 to match the UI crate (#1169) (#1188) --- Cargo.toml | 2 +- README.md | 2 ++ docs/architecture.md | 2 +- docs/development.md | 4 ++-- xtask/src/version.rs | 14 ++++++++++++++ 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 70a65b6b9..ddea910f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] version = "0.6.0" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] diff --git a/README.md b/README.md index a0e985b1a..4f5792db9 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,8 @@ PdfCraft is a clean-room implementation. Its behaviour comes from the ISO 32000 ## Get started +Building needs Rust 1.95 or later. + ```sh git clone https://github.com/storytold/pdfcraft cd pdfcraft diff --git a/docs/architecture.md b/docs/architecture.md index eb56cdb3e..013633b77 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,7 +8,7 @@ crate's `README.md` has the detail. ## Shape -One Cargo workspace (`Cargo.toml`, edition 2024, Rust ≥ 1.90, `MIT OR Apache-2.0`), 31 library +One Cargo workspace (`Cargo.toml`, edition 2024, Rust ≥ 1.95, `MIT OR Apache-2.0`), 31 library crates in `crates/`, three binaries in `apps/`, build tooling in `xtask/`, and patched third-party crates in `vendor/`. About 152k lines of Rust and 1,765 tests (2026-10-10). Rust only: no handwritten JS/TS, in the product or the build. diff --git a/docs/development.md b/docs/development.md index 535311891..634c4bb26 100644 --- a/docs/development.md +++ b/docs/development.md @@ -22,8 +22,8 @@ Cargo stores custom-profile artifacts separately under `target/low-disk`. Switch normal and low-disk profiles can therefore leave both sets of artifacts on disk. To remove only the low-disk artifacts, run `cargo clean --profile low-disk`. -The UI crate and complete desktop/web builds require Rust 1.95 or later, matching the pinned -`craft-ui` dependency. The workspace's core-crate minimum is unchanged. +Building PdfCraft requires Rust 1.95 or later: the UI crate and its `egui` and `craft-ui` +dependencies need it, and the workspace `rust-version` states the same minimum. ## Interface appearance diff --git a/xtask/src/version.rs b/xtask/src/version.rs index e11c99d76..067365317 100644 --- a/xtask/src/version.rs +++ b/xtask/src/version.rs @@ -154,4 +154,18 @@ mod tests { let text = std::fs::read_to_string(crate::gates::root().join("Cargo.toml")).unwrap(); validate(&read(&text).unwrap()).unwrap(); } + + /// The `rust-version = "1.NN"` line of a manifest, as the minor number. + fn minor_of_rust_version(manifest: &str) -> u32 { + let line = manifest.lines().find(|l| l.starts_with("rust-version = \"")).unwrap(); + line.split('"').nth(1).unwrap().split('.').nth(1).unwrap().parse().unwrap() + } + + #[test] + fn workspace_rust_version_covers_the_ui_crate() { + let root = crate::gates::root(); + let workspace = std::fs::read_to_string(root.join("Cargo.toml")).unwrap(); + let ui = std::fs::read_to_string(root.join("crates/ui-egui/Cargo.toml")).unwrap(); + assert!(minor_of_rust_version(&workspace) >= minor_of_rust_version(&ui)); + } } From 9622f29d046055b5e7cd4b23598766a8e05f1cf6 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:32:03 +0200 Subject: [PATCH 08/16] M5: preserve comment identities during partial XFDF and FDF imports (#1144) Reuse a replaced named comment's object reference so omitted replies and review statuses remain attached. Keep popup replacement behavior and let the final duplicate record control its deferred reply relationship. Resolve indirect names in existing PDF comments so updates match their original objects instead of creating duplicate parents. Cover existing-PDF updates, incremental and full saves, subsequent FDF exchange, page-scoped names, unnamed additions, duplicate records and indirect names with surviving reply and review-status references. Co-authored-by: MMexicann Co-authored-by: Codex --- ROADMAP.md | 2 + crates/xfdf/src/lib.rs | 26 +++-- crates/xfdf/src/tests.rs | 230 ++++++++++++++++++++++++++++++++++++++- docs/roadmap.md | 3 +- 4 files changed, 252 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a893c2ef9..2c685c6e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,8 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M14, #307 Plan 3):** Engine open and full refresh use one page/content traversal to populate marks and added content. The independent public scans remain available; the combined scanner borrows direct objects and holds referenced objects through `Arc` instead of cloning scan-only `/Contents` arrays or `/PCAdded` dictionaries. Differential tests cover an indirect `/PCAdded` dictionary as well as ordering, malformed/direct/indirect/repeated entries and `/UserUnit`. Exact `bbbe600`→final release CLI measurements used 10 alternating pairs: wall-time medians were 183.645→174.440 ms on 10,000 marker-free pages (10/10 wins), 33.587→32.752 ms on 1,500 mixed pages (8/10), and 24.331→20.133 ms on the 100,000-entry array stress fixture (10/10). The stress fixture's maximum-RSS median fell 28,655,616→19,038,208 B; peak-footprint medians were 14,747,136→14,673,408 B. From P0, the follow-up stress run was 20.886→20.201 ms (10/10), with RSS 23,953,408→19,087,360 B and peak footprint 14,706,144→14,697,952 B. `doc_open` stdout hashes matched every pair. The ancillary `organize` binding preserves the prior `prune_legacy` update condition. Estimates unchanged. - **2026-10-11 (M4, Organize overview):** Opening Organize pages from All tools, Home, the tool palette or `ui.set tool=organize` opens the thumbnail grid directly. Checkboxes toggle individual pages without modifier keys; page controls rotate either way or delete one page, while the toolbar acts on the selection. Portrait and landscape previews retain their aspect ratio. UI and control regressions cover entry, selection, single-page versus batch actions, undo, permissions and retaining the last page. Milestone and effort estimates unchanged. +- **2026-10-11 (M5, partial comment imports):** Replacing an editable named comment through XFDF or FDF (including an indirect name in the source PDF) preserves its object identity, so existing replies and review statuses remain attached after saving, reopening and exporting again. Replaced pop-ups are still removed. Locked comments, redaction marks and their pop-ups stay unchanged, including when their names are indirect; imported redactions remain visible. Synthetic round-trip tests cover both formats, incremental and full saves; milestone and effort estimates unchanged. + - **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged. - **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %). - **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted. diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index beec3937e..93bf0b70a 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -794,7 +794,7 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result Result { + doc.set(r, Object::Dict(d)); + r + } + None => doc.add(Object::Dict(d)), + }; // Our own appearance where we can draw one; others keep none (viewers draw a default). let _ = pdfcraft_annot::set_appearance(doc, r); kept.push(Object::Ref(r)); @@ -831,7 +840,7 @@ fn import_xfdf(doc: &mut Document, text: &str) -> Result { let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); // Comments: parents first, then replies (whose /IRT needs the parent's reference). let mut by_name: HashMap = HashMap::new(); - let mut later: Vec<(ObjRef, String)> = Vec::new(); + let mut later: HashMap = HashMap::new(); if let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) { for node in annots.children().filter(|c| c.is_element()) { let tag = node.tag_name().name().to_ascii_lowercase(); @@ -839,11 +848,13 @@ fn import_xfdf(doc: &mut Document, text: &str) -> Result { let Some(page) = node.attribute("page").and_then(|p| p.parse::().ok()).and_then(|p| pages.get(p).copied()) else { continue }; let Some(d) = annot_from_xml(node, subtype, page) else { continue }; let r = place(doc, page, d)?; + // A later same-name record replaces this comment, including its reply relation. + later.remove(&r); if let Some(nm) = node.attribute("name") { by_name.insert(nm.to_string(), r); } if let Some(irt) = node.attribute("inreplyto") { - later.push((r, irt.to_string())); + later.insert(r, irt.to_string()); } // A pop-up window. if let Some(pop) = node.children().find(|c| c.has_tag_name("popup")) @@ -1028,7 +1039,7 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { // Comments: parents first, then replies (whose /IRT needs the parent's new reference). let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); let mut placed: HashMap = HashMap::new(); - let mut later: Vec<(ObjRef, Option, Option)> = Vec::new(); + let mut later: HashMap, Option)> = HashMap::new(); for a in body.get(b"Annots").map(|a| fdf.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { let Some(mut src) = fdf.resolve(&a).as_dict().cloned() else { continue }; // The parent is another comment: link it below rather than copying it in. @@ -1044,16 +1055,17 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { d.remove(b"Page"); d.set(b"P".to_vec(), Object::Ref(page)); let r = place(doc, page, d)?; + later.remove(&r); if let Some(fr) = a.as_ref() { placed.insert(fr, r); } if let Some(irt) = irt { let parent_name = fdf.resolve(&irt).as_dict().and_then(|p| text_of(&fdf, p, b"NM")); - later.push((r, irt.as_ref(), parent_name)); + later.insert(r, (irt.as_ref(), parent_name)); } report.comments += 1; } - for (r, parent_obj, parent_name) in later { + for (r, (parent_obj, parent_name)) in later { // The parent imported from this file, else one already in the document with its name. let parent = parent_obj.and_then(|p| placed.get(&p).copied()).or_else(|| parent_name.and_then(|nm| find_by_name(doc, &nm))); if let Some(parent) = parent.filter(|p| *p != r) { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 31e7f5c3f..fdf4056eb 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, summaries}; -use pdfcraft_cos::{SaveOptions, write_full}; +use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_forms::{NewField, add_field, fields, set_value}; use super::*; @@ -399,6 +399,234 @@ fn reimporting_a_note_replaces_its_popup() { assert!(refs.contains(&parent), "the pop-up belongs to a note that is still on the page"); } +/// A partial import updates the parent without detaching replies and review statuses that +/// weren't included in the data file. FDF export only carries parents still on a page. +#[test] +fn replacing_a_note_keeps_existing_replies_and_review_status() { + let xfdf = concat!( + r#""#, + r#"Updated note"#, + r#""#, + ); + let mut incoming = blank(); + import(&mut incoming, xfdf.as_bytes()).unwrap(); + let mut original = filled(); + let index = summaries(&original).iter().find(|s| s.name.as_deref() == Some("n1")).unwrap().index; + pdfcraft_annot::set_review_state( + &mut original, + 0, + index, + pdfcraft_annot::ReviewState::Accepted, + "Reviewer", + &Meta { date: None, id: "status".into() }, + ) + .unwrap(); + let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap()); + for data in [xfdf.as_bytes().to_vec(), export_fdf(&incoming, true, false, "notes.pdf")] { + // Start with the parent, reply and review status in the existing PDF revision. + let mut doc = Document::open(original.clone()).unwrap(); + let mut want = threads(&doc); + want.sort(); + assert_eq!(want.iter().filter(|(_, parent, _)| parent.as_deref() == Some("n1")).count(), 2); + assert_eq!(import(&mut doc, &data).unwrap().comments, 1); + let note = summaries(&doc).into_iter().find(|s| s.name.as_deref() == Some("n1")).unwrap(); + assert_eq!(note.contents.as_deref(), Some("Updated note")); + + // Check the saved object graph and a subsequent export/import, rather than relying on + // summaries resolving an obsolete parent's name from an object outside /Annots. + for incremental in [true, false] { + let saved = if incremental { + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.starts_with(original.as_slice()), "an incremental save preserves the original revision"); + saved + } else { + write_full(&doc, &SaveOptions::default()).unwrap() + }; + let reopened = Document::open(Arc::new(saved)).unwrap(); + let note = summaries(&reopened).into_iter().find(|s| s.name.as_deref() == Some("n1")).unwrap(); + assert_eq!(note.contents.as_deref(), Some("Updated note")); + let fdf = export_fdf(&reopened, true, false, "notes.pdf"); + let mut dst = blank(); + import(&mut dst, &fdf).unwrap(); + let mut got = threads(&dst); + got.sort(); + assert_eq!(got, want, "partial comment import must preserve reply and status threads (incremental={incremental})"); + } + } +} + +#[test] +fn replacing_an_indirectly_named_note_preserves_its_identity_and_thread() { + let initial = concat!( + r#"Original"#, + r#"Reply"#, + r#""#, + r#""#, + ); + let update = br#"Updated"#; + let mut original = blank(); + for name in ["Name", "Agree", "Pick"] { + pdfcraft_forms::delete_field(&mut original, name).unwrap(); + } + import(&mut original, initial.as_bytes()).unwrap(); + let annotation_refs = |doc: &Document| { + let page = pdfcraft_model::pages(doc)[0].obj; + doc.get(page) + .as_dict() + .unwrap() + .get(b"Annots") + .map(|a| doc.resolve(a)) + .unwrap() + .as_array() + .unwrap() + .iter() + .filter_map(Object::as_ref) + .collect::>() + }; + let parent = annotation_refs(&original)[0]; + let indirect_name = original.add(PdfString::text("n1")); + original.update_dict(parent, |d| d.set(b"NM".to_vec(), Object::Ref(indirect_name))).unwrap(); + let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap()); + let mut incoming = blank(); + import(&mut incoming, update).unwrap(); + for data in [update.to_vec(), export_fdf(&incoming, true, false, "notes.pdf")] { + let mut doc = Document::open(original.clone()).unwrap(); + let refs = annotation_refs(&doc); + assert_eq!(refs.len(), 3); + let parent = refs.iter().copied().find(|r| text_of(&doc, doc.get(*r).as_dict().unwrap(), b"NM").as_deref() == Some("n1")).unwrap(); + assert!(doc.get(parent).as_dict().unwrap().get(b"NM").unwrap().as_ref().is_some(), "the saved source name is indirect"); + let mut want = threads(&doc); + want.sort(); + assert_eq!(import(&mut doc, &data).unwrap().comments, 1); + let updated = annotation_refs(&doc); + assert_eq!(updated.len(), 3, "a valid indirect name must replace its comment instead of duplicating it"); + assert!(updated.contains(&parent), "replacement preserves the source object's identity"); + assert_eq!(text_of(&doc, doc.get(parent).as_dict().unwrap(), b"Contents").as_deref(), Some("Updated")); + for child in refs.into_iter().filter(|r| *r != parent) { + assert_eq!(doc.get(child).as_dict().unwrap().reference(b"IRT"), Some(parent)); + } + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.starts_with(original.as_slice())); + let reopened = Document::open(Arc::new(saved)).unwrap(); + let mut got = threads(&reopened); + got.sort(); + assert_eq!(got, want); + assert_eq!(summaries(&reopened).iter().find(|s| s.name.as_deref() == Some("n1")).unwrap().contents.as_deref(), Some("Updated")); + } +} + +#[test] +fn replacement_matches_named_comments_only_on_their_page() { + let xfdf = concat!( + r#""#, + r#"First page"#, + r#"Second page"#, + r#"Unnamed"#, + r#""#, + ); + let mut doc = blank(); + import(&mut doc, xfdf.as_bytes()).unwrap(); + import(&mut doc, xfdf.as_bytes()).unwrap(); + let notes = summaries(&doc); + assert_eq!(notes.len(), 4, "named comments are replaced, unnamed comments are added"); + for (page, text) in [(0, "First page"), (1, "Second page")] { + let note = notes.iter().find(|s| s.page == page && s.name.as_deref() == Some("same")).unwrap(); + assert_eq!(note.contents.as_deref(), Some(text)); + } + assert_eq!(notes.iter().filter(|s| s.name.is_none()).count(), 2); +} + +#[test] +fn the_last_same_named_record_controls_its_reply_parent() { + let mut incoming = blank(); + let page = pdfcraft_model::pages(&incoming)[0].obj; + let mut refs = Vec::new(); + for name in ["parent", "duplicate", "duplicate"] { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Text")); + d.set(b"NM".to_vec(), PdfString::text(name)); + d.set(b"Rect".to_vec(), arr(&[10.0, 10.0, 30.0, 30.0])); + if refs.len() == 1 { + d.set(b"IRT".to_vec(), Object::Ref(refs[0])); + } + refs.push(incoming.add(d)); + } + incoming.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(refs.into_iter().map(Object::Ref).collect()))).unwrap(); + for data in [export_xfdf(&incoming, true, false, "notes.pdf").into_bytes(), export_fdf(&incoming, true, false, "notes.pdf")] { + let mut doc = blank(); + import(&mut doc, &data).unwrap(); + let notes = summaries(&doc); + assert_eq!(notes.len(), 2); + let last = notes.iter().find(|s| s.name.as_deref() == Some("duplicate")).unwrap(); + assert_eq!(last.in_reply_to, None, "a replaced record's deferred /IRT must not attach the final standalone comment"); + } +} + +#[test] +fn indirectly_named_protected_comments_and_popups_are_not_replaced() { + let xfdf = br#"Updated"#; + let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Redact /NM (n1) /Page 0 /Rect [0 0 1 1] /F 39 /Contents (Updated) >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + let annotation_refs = |doc: &Document| { + pdfcraft_model::pages(doc)[0] + .dict + .get(b"Annots") + .map(|a| doc.resolve(a)) + .unwrap() + .as_array() + .unwrap() + .iter() + .filter_map(Object::as_ref) + .collect::>() + }; + for (subtype, flags) in [("Text", LOCKED), ("Redact", 0)] { + let mut original = blank(); + let page = pdfcraft_model::pages(&original)[0].obj; + let name = original.add(PdfString::text("n1")); + let mut protected = Dict::new(); + protected.set(b"Type".to_vec(), Object::name("Annot")); + protected.set(b"Subtype".to_vec(), Object::name(subtype)); + protected.set(b"NM".to_vec(), Object::Ref(name)); + protected.set(b"Rect".to_vec(), arr(&[10.0, 10.0, 30.0, 30.0])); + protected.set(b"F".to_vec(), Object::Int(flags)); + protected.set(b"Contents".to_vec(), PdfString::text("Original")); + protected.set(b"CustomData".to_vec(), PdfString::text("preserved")); + let parent = original.add(protected); + let mut popup = Dict::new(); + popup.set(b"Subtype".to_vec(), Object::name("Popup")); + popup.set(b"Rect".to_vec(), arr(&[40.0, 10.0, 140.0, 60.0])); + popup.set(b"Parent".to_vec(), Object::Ref(parent)); + let popup = original.add(popup); + original.update_dict(parent, |d| d.set(b"Popup".to_vec(), Object::Ref(popup))).unwrap(); + let mut annots = annotation_refs(&original); + annots.extend([parent, popup]); + original.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(annots.into_iter().map(Object::Ref).collect()))).unwrap(); + let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap()); + for data in [xfdf.as_slice(), fdf.as_slice()] { + let mut doc = Document::open(original.clone()).unwrap(); + let before = annotation_refs(&doc); + let parent = before.iter().copied().find(|r| text_of(&doc, doc.get(*r).as_dict().unwrap(), b"NM").as_deref() == Some("n1")).unwrap(); + let protected = doc.get(parent); + let popup_ref = protected.as_dict().unwrap().reference(b"Popup").unwrap(); + let popup = doc.get(popup_ref); + assert!(protected.as_dict().unwrap().get(b"NM").unwrap().as_ref().is_some()); + assert_eq!(import(&mut doc, data).unwrap().comments, 1); + let after = annotation_refs(&doc); + assert_eq!(after.len(), before.len() + 1); + assert!(after.contains(&parent) && after.contains(&popup_ref)); + assert_eq!(doc.get(parent), protected, "the protected {subtype} must not change, including its unknown data"); + assert_eq!(doc.get(popup_ref), popup, "the protected comment's popup must not change"); + let added = after.into_iter().find(|r| !before.contains(r)).unwrap(); + let added = doc.get(added); + let added = added.as_dict().unwrap(); + assert!(added.get(b"NM").is_none(), "the imported comment must relinquish the protected name"); + assert_eq!(added.int(b"F"), Some(4), "an imported redaction stays printable with Invisible/Hidden/NoView removed"); + let reopened = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(reopened.get(parent), protected); + assert_eq!(reopened.get(popup_ref), popup); + } + } +} + #[test] fn imported_redact_marks_cannot_replace_a_locked_mark_or_hide_themselves() { let mut doc = blank(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 154be7d4d..a059aa317 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -79,7 +79,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | -| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | @@ -143,6 +143,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-11 | minor | M4: direct Organize overview, checkbox selection and individual page actions. Estimates unchanged. | | 2026-10-11 | minor | Synced with closed issues: encrypted output opens in Acrobat (#774, #836), so gap 1 left rank 0 and convert and share (32–55 h, the gap's 8–15 h removed); organize, XFDF and multi-object move fixes (#817, #821, #809, #819, #844); the #816 panic and the #799–#821 batch fixed; rank 1 names web printing (Windows shipped, #756). Stage and readiness numbers not re-measured | | 2026-10-11 | minor | M6: respect existing form-font encodings; retain source fonts on appearance fallback. Estimates unchanged. | +| 2026-10-11 | minor | M5: partial XFDF/FDF imports preserve existing replies and review statuses through save and export. Estimates unchanged. | | 2026-10-11 | minor | M6: keep accepted form values when Tab reopens the same field before Save. Estimates unchanged. | | 2026-10-11 | minor | M13: reject malformed CLI --root options before dispatch. Estimates unchanged. | | 2026-10-11 | minor | M14: Traditional Chinese accessibility statuses, script-rule meaning and Taiwan form/table terminology; added a missing table-row explanation and catalog regressions. Estimates unchanged. | From 036e00f2a105dc06e3c23062763a822079856bf8 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Mon, 12 Oct 2026 00:32:16 +0200 Subject: [PATCH 09/16] Test PDF viewer trackpad pinch zoom (#1148) --- crates/ui-egui/tests/ui.rs | 24 ++++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index b4c62bd3e..7b42f459d 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -555,6 +555,30 @@ fn zoom_keeps_the_point_under_the_cursor_still() { assert!((after - before).length() < 2.0, "moved from {before:?} to {after:?}"); } +#[test] +fn trackpad_pinch_zooms_the_pdf_under_the_pointer() { + let mut h = harness(|app| { + app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + }); + 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); + h.run_steps(1); + h.event(egui::Event::Zoom(1.5)); + h.run_steps(4); + let enlarged = &h.state().views[0]; + assert!((enlarged.zoom - zoom * 1.5).abs() < 0.01); + let after = enlarged.glyph_screen_pos(0, 4).expect("glyph still on screen"); + assert!((after - before).length() < 2.0, "moved from {before:?} to {after:?}"); + + h.event(egui::Event::Zoom(0.5)); + h.run_steps(4); + assert!((h.state().views[0].zoom - zoom * 0.75).abs() < 0.01); +} + #[test] fn selection_works_in_every_view_rotation() { for deg in ["90", "180", "270"] { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2667459a2..1ab0ea92a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -926,8 +926,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::zoom_keeps_the_point_under_the_cursor_still"] -notes = "" +evidence = ["crates/ui-egui/tests/ui.rs::zoom_keeps_the_point_under_the_cursor_still", "crates/ui-egui/tests/ui.rs::trackpad_pinch_zooms_the_pdf_under_the_pointer"] +notes = "A trackpad pinch zooms the PDF beneath the pointer in either direction, keeping the same page point in place. The viewer handles the platform's zoom gesture where it is available." [[feature]] id = "view.zoom-to-percent" From 38df4016877ead3eee60cc9efb6f3256ae544a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Cie=C5=9Blik?= <132496025+Hathor875@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:32:29 +0200 Subject: [PATCH 10/16] M4: replace selected pages from organize toolbar and context menus (#786) (#1149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'Replace pages…' action to the Organize Pages toolbar, organize grid thumbnail context menu, and Pages panel context menu to replace selected pages. - Add ViewAction::ReplacePages triggering replace_pages_dialog() - Expose 'Replace pages…' button with 'replace' icon on the organize toolbar - Expose 'Replace pages…' on the organize grid and Pages panel thumbnail context menus - Update parity evidence for organize.replace-pages - Add integration tests for toolbar and context menu replace actions in crates/ui-egui/tests/editing.rs Co-authored-by: KrzysztofC --- crates/engine/src/compare.rs | 3 +- crates/ui-egui/src/canvas.rs | 16 ++++++- crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/panels.rs | 4 ++ crates/ui-egui/src/redact_ui.rs | 4 +- crates/ui-egui/tests/editing.rs | 78 +++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 7 files changed, 102 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 04dc83cd9..be1ef0cfa 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -64,13 +64,12 @@ impl Session { let (mut ra, mut rb) = (renderer(a), renderer(b)); let scale = dpi.clamp(18.0, 150.0) / 72.0; let mut out = Vec::new(); - for page in 0..a.info.pages.len().min(b.info.pages.len()) { + for (page, info) in b.info.pages.iter().enumerate().take(a.info.pages.len()) { let req = pdfcraft_render::RenderRequest { page, scale, ..Default::default() }; let (x, y) = (ra.render(req), rb.render(req)); if x.error.is_some() || y.error.is_some() { continue; } - let info = &b.info.pages[page]; let s = y.width as f32 / info.width.max(1e-3); for r in pdfcraft_compare::visual_regions((&y.rgba, y.width, y.height), (&x.rgba, x.width, x.height), 24) { let p = info.view_to_user(r[0] as f32 / s, r[1] as f32 / s); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f829c9a3b..adb67fb52 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -499,6 +499,8 @@ pub enum ViewAction { InsertFromFile, /// Insert files at a gap of the page grid (0 = before the first page). InsertFromFileAt(usize), + /// Replace the selected pages with pages from another file. + ReplacePages, /// Save the document as the grid shows it. Save, Extract, @@ -3805,6 +3807,9 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b if icons::button(ui, "file-input", 30.0, false, tl!("Insert pages from a file…")).clicked() { view.pending_action = Some(ViewAction::InsertFromFile); } + if icons::button(ui, "replace", 30.0, false, tl!("Replace pages…")).clicked() { + view.pending_action = Some(ViewAction::ReplacePages); + } if icons::button(ui, "file-output", 30.0, false, tl!("Extract pages to a new document")).clicked() { view.pending_action = Some(ViewAction::Extract); } @@ -4085,12 +4090,21 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool if resp.double_clicked() { open_page = Some(i); } - // Right-click: Cut, Copy, Paste (on the selection, or this page). + // Right-click: Extract, Replace, Cut, Copy, Paste (on the selection, or this page). resp.context_menu(|ui| { if !view.selected.contains(&i) { view.selected = [i].into(); view.current = i; } + if ui.button(tl!("Extract pages")).clicked() { + view.pending_action = Some(ViewAction::Extract); + ui.close(); + } + if ui.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() { + view.pending_action = Some(ViewAction::ReplacePages); + ui.close(); + } + ui.separator(); if ui.add_enabled(editable, egui::Button::new(tl!("Cut"))).clicked() { view.pending_action = Some(ViewAction::CopyPages { cut: true }); ui.close(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 41126bd05..81115124c 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -204,6 +204,7 @@ impl PdfCraftApp { match self.views.get_mut(i).and_then(|v| v.pending_action.take()) { Some(crate::canvas::ViewAction::InsertFromFile) => self.insert_from_file_dialog(), Some(crate::canvas::ViewAction::InsertFromFileAt(at)) => self.insert_from_file_at(Some(at)), + Some(crate::canvas::ViewAction::ReplacePages) => self.replace_pages_dialog(), Some(crate::canvas::ViewAction::Save) => { self.save_active(SaveTarget::InPlace); } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index aebf5ec6f..f3d2cd130 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1045,6 +1045,10 @@ 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.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() { + view.pending_action = Some(crate::canvas::ViewAction::ReplacePages); + ui.close(); + } ui.separator(); if ui.add_enabled(editable, egui::Button::new(tl!("Cut"))).clicked() { view.pending_action = Some(crate::canvas::ViewAction::CopyPages { cut: true }); diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index c1ae53b76..4af458950 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -196,7 +196,7 @@ impl PdfCraftApp { let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); let mut edits = Vec::new(); let author = self.comment_prefs.author.clone(); - for page in 0..doc.info.pages.len() { + for (page, page_info) in doc.info.pages.iter().enumerate() { let out = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); let Some(text) = out.text else { continue }; let hits = match d.mode { @@ -204,7 +204,7 @@ impl PdfCraftApp { _ => needles.iter().flat_map(|w| text.find(w)).collect(), }; for h in hits { - let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| doc.info.pages[page].view_rect_to_quad(r)).collect(); + let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| page_info.view_rect_to_quad(r)).collect(); if !quads.is_empty() { edits.push(self.redact_prefs.mark(page, quads, &author)); } diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index ba4bbefa0..40e85436e 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1324,6 +1324,84 @@ fn extract_button_copies_selected_pages_to_a_new_tab() { assert!(!app.session.get(app.views[0].id).unwrap().dirty); } +#[test] +fn replace_button_replaces_selected_pages_in_organize() { + let dir = temp_path("replace-btn"); + std::fs::create_dir_all(&dir).unwrap(); + let other = dir.join("other.pdf"); + std::fs::write(&other, fixture(5)).unwrap(); + + let mut h = organize(3); + h.get_by_label("Page 2").click(); + h.run_steps(1); + h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + h.state_mut().pick_override = Some(vec![other.to_string_lossy().into_owned()]); + h.get_by_label("Replace pages…").click(); + h.run_steps(3); + egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages"); + assert_eq!(h.state().replace_draft.as_ref().unwrap().from, 2); + assert_eq!(h.state().replace_draft.as_ref().unwrap().to, 3); + egui_kittest::kittest::Queryable::get_by_label(&h, "OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.info.pages.len(), 3); + assert_eq!(doc.can_undo(), Some("Replace pages")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn replace_pages_from_thumbnail_context_menu() { + let dir = temp_path("replace-ctx"); + std::fs::create_dir_all(&dir).unwrap(); + let other = dir.join("other.pdf"); + std::fs::write(&other, fixture(5)).unwrap(); + + let mut h = organize(3); + h.state_mut().pick_override = Some(vec![other.to_string_lossy().into_owned()]); + h.get_by_label("Page 2").click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Replace pages…").last().unwrap().click(); + h.run_steps(3); + egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages"); + assert_eq!(h.state().replace_draft.as_ref().unwrap().from, 2); + assert_eq!(h.state().replace_draft.as_ref().unwrap().to, 2); + egui_kittest::kittest::Queryable::get_by_label(&h, "OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.info.pages.len(), 3); + assert_eq!(doc.can_undo(), Some("Replace page")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn replace_pages_from_pages_panel_context_menu() { + let dir = temp_path("replace-panel-ctx"); + std::fs::create_dir_all(&dir).unwrap(); + let other = dir.join("other.pdf"); + std::fs::write(&other, fixture(5)).unwrap(); + + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + h.state_mut().pick_override = Some(vec![other.to_string_lossy().into_owned()]); + 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("Replace pages…").last().unwrap().click(); + h.run_steps(3); + egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages"); + assert_eq!(h.state().replace_draft.as_ref().unwrap().from, 2); + assert_eq!(h.state().replace_draft.as_ref().unwrap().to, 2); + egui_kittest::kittest::Queryable::get_by_label(&h, "OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.info.pages.len(), 4); + assert_eq!(doc.can_undo(), Some("Replace page")); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn inserting_a_file_goes_after_the_selection_and_undoes() { let mut h = organize(2); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1ab0ea92a..0e68fc81d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2770,7 +2770,7 @@ milestone = "M4" status = "shipped" commands = ["page.replace"] tools = ["page_replace"] -evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/automation/tests/automation.rs::replacing_pages_through_tools"] +evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools"] notes = "content, resources and boxes replaced; links, comments, fields and bookmarks kept" [[feature]] From 0ef75d294c3c645b704459d3e0f437dc90abcf4f Mon Sep 17 00:00:00 2001 From: Todd Pederson <34252100+Toddpe@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:34:16 -0400 Subject: [PATCH 11/16] UI: checkboxes and radio buttons are outlined, and checkboxes are square (#1173) * UI: checkboxes draw a rounded-square box, not a radio-button circle The theme's 6 px widget radius made egui's 14 px checkbox box a circle, so View > Page display's Continuous scrolling and Show cover page looked like two more layout radio buttons. On the light theme an unticked box had no outline and the panel's fill, so it couldn't be seen. Every checkbox (81 call sites) now goes through widgets::checkbox / checkbox_without_text, which draws the box with a 3 px radius and a 1 px border outline, keeping an outline the caller already set (dialogs). clippy.toml disallows egui::Ui::checkbox and egui::Checkbox::{new, without_text} so new checkboxes use the helper too. Co-Authored-By: Claude Opus 5.5 * UI: radio buttons are outlined and filled, so they show on the light theme On the light theme the field colour is the panels' white and widgets have no outline, so an unselected radio button (and an unticked checkbox) was invisible outside dialogs: View > Page display showed only the selected dot. Every radio button (79 call sites) now goes through widgets::radio / radio_value, sharing the checkbox helper's styling: a 1 px border outline, text_muted on hover, and on the light theme the plain fill (hover) as dialogs already had, keeping what the caller set. The dark theme keeps its fills. clippy.toml also disallows egui::Ui::radio, egui::Ui::radio_value and egui::RadioButton::new. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + clippy.toml | 11 +++ crates/ui-egui/src/a11y_ui.rs | 12 +-- crates/ui-egui/src/appearance.rs | 3 +- crates/ui-egui/src/bulk_fields.rs | 4 +- crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/chrome.rs | 10 +- crates/ui-egui/src/comment_props.rs | 2 +- crates/ui-egui/src/comments_panel.rs | 5 +- crates/ui-egui/src/create_ui.rs | 7 +- crates/ui-egui/src/dialogs.rs | 45 ++++----- crates/ui-egui/src/forms_ui.rs | 3 +- crates/ui-egui/src/js_ui.rs | 24 ++--- crates/ui-egui/src/link_ui.rs | 4 +- crates/ui-egui/src/marks_ui.rs | 22 ++--- crates/ui-egui/src/measure_ui.rs | 14 +-- crates/ui-egui/src/ocr_ui.rs | 6 +- crates/ui-egui/src/optimize_ui.rs | 22 ++--- crates/ui-egui/src/pageboxes.rs | 11 ++- crates/ui-egui/src/prepare.rs | 39 ++++---- crates/ui-egui/src/print_ui.rs | 34 +++---- crates/ui-egui/src/protect.rs | 8 +- crates/ui-egui/src/redact_ui.rs | 28 +++--- crates/ui-egui/src/search_ui.rs | 5 +- crates/ui-egui/src/sign_ui.rs | 22 ++--- crates/ui-egui/src/widgets.rs | 134 +++++++++++++++++++++++++++ 26 files changed, 321 insertions(+), 161 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2c685c6e7..818c04976 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-11 (UI, checkboxes and radio buttons):** Checkboxes no longer look like radio buttons, and on the light theme neither is invisible. With the theme's 6 px widget radius egui drew a checkbox's 14 px box as a circle, so View ▸ Page display's Continuous scrolling and Show cover page read as two more layout choices; on the light theme an unticked box or an unselected radio button was white on white with no edge. Every checkbox (81) and radio button (79) now goes through `widgets::checkbox` / `widgets::radio` / `widgets::radio_value`: a 1 px outline (darker on hover), the plain fill on the light theme as dialogs already had, and for checkboxes a rounded-square box (3 px), keeping what the caller set (dialogs, the search panel). `clippy.toml` disallows `ui.checkbox`, `ui.radio`, `ui.radio_value`, `egui::Checkbox` and `egui::RadioButton` so new ones use them too. Estimates unchanged. - **2026-10-11 (M13, #971):** `text_find` now bounds each page's temporary match-range list to the remaining result limit plus one, which is enough to detect and return a continuation without allocating every match on a dense page. Added limited-search coverage; the existing end-to-end continuation test passes. The text cache byte budget and per-page extraction warnings remain open. - **2026-10-11 (M7, #594):** Ordinary paragraph body drags now use the existing source-preserving object translation path; edge drags still rewrap. An in-progress drag is discarded if the document generation changes, and added-content gestures keep their own edit handler. Added real-shell source regressions for overlapping and identical labels, history, save/reopen, partial overlap, kerning, rotation/crop/UserUnit and atomic refusal. Linux validation passed: focused UI/engine regressions, exact-base source-loss failures, native drag/undo/redo/save/reopen with independent Poppler renders, and all eight CI gates. Text replacement and resizing over unrelated overlapping content remain outside this repair. Estimates unchanged. diff --git a/clippy.toml b/clippy.toml index 4b6c79f55..7f2fd5305 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,3 +2,14 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true allow-indexing-slicing-in-tests = true + +# With the theme's widget visuals egui draws a checkbox's box as a circle, like a radio button, +# and on the light theme neither has an outline or a fill that shows on a white panel. +disallowed-methods = [ + { path = "egui::Ui::checkbox", reason = "use checkbox from crates/ui-egui/src/widgets.rs, whose box is a square" }, + { path = "egui::Checkbox::new", reason = "use checkbox from crates/ui-egui/src/widgets.rs, whose box is a square" }, + { path = "egui::Checkbox::without_text", reason = "use checkbox_without_text from crates/ui-egui/src/widgets.rs, whose box is a square" }, + { path = "egui::Ui::radio", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, + { path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, + { path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, +] diff --git a/crates/ui-egui/src/a11y_ui.rs b/crates/ui-egui/src/a11y_ui.rs index 57a7ce86a..40505cc40 100644 --- a/crates/ui-egui/src/a11y_ui.rs +++ b/crates/ui-egui/src/a11y_ui.rs @@ -83,12 +83,12 @@ pub(crate) fn options_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ui.add_space(8.0); }; group(ui, "Report Options", &mut |ui| { - ui.checkbox(&mut o.create_report, tl!("Create accessibility report")); + ui.add(widgets::checkbox(&mut o.create_report, tl!("Create accessibility report"))); }); group(ui, "Page Range", &mut |ui| { ui.horizontal(|ui| { - ui.radio_value(&mut o.all_pages, true, tl!("All pages in document")); - ui.radio_value(&mut o.all_pages, false, tl!("Pages from")); + widgets::radio_value(ui, &mut o.all_pages, true, tl!("All pages in document")); + widgets::radio_value(ui, &mut o.all_pages, false, tl!("Pages from")); ui.add_enabled(!o.all_pages, egui::DragValue::new(&mut o.from).range(1..=pages)); ui.label(tl!("to")); ui.add_enabled(!o.all_pages, egui::DragValue::new(&mut o.to).range(1..=pages)); @@ -107,7 +107,7 @@ pub(crate) fn options_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ui.add_space(4.0); for r in Rule::ALL.into_iter().filter(|r| r.category() == o.category) { let mut checked = o.rules.contains(&r); - if ui.checkbox(&mut checked, tl!(r.description())).changed() { + if ui.add(widgets::checkbox(&mut checked, tl!(r.description()))).changed() { if checked { o.rules.insert(r); } else { @@ -126,7 +126,7 @@ pub(crate) fn options_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) } }); }); - ui.checkbox(&mut o.show_dialog, tl!("Show this dialog when the Checker starts")); + ui.add(widgets::checkbox(&mut o.show_dialog, tl!("Show this dialog when the Checker starts"))); ui.add_space(10.0); let (mut start, mut cancel) = (false, false); ui.horizontal(|ui| { @@ -507,7 +507,7 @@ pub(crate) fn alt_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> let l = ui.label(tl!("Alternate text")); ui.add(crate::widgets::block(&mut d.texts[i]).desired_rows(3).desired_width(380.0).hint_text(tl!("Describe the figure"))).labelled_by(l.id); }); - ui.checkbox(&mut d.decorative[i], tl!("Decorative figure")); + ui.add(widgets::checkbox(&mut d.decorative[i], tl!("Decorative figure"))); ui.add_space(10.0); let (mut save, mut cancel) = (false, false); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/appearance.rs b/crates/ui-egui/src/appearance.rs index bb804314a..5782b3d59 100644 --- a/crates/ui-egui/src/appearance.rs +++ b/crates/ui-egui/src/appearance.rs @@ -5,6 +5,7 @@ use egui::{Rect, RichText, pos2, vec2}; use crate::PdfCraftApp; use crate::theme::{self, ThemeKind, ThemePreference, Tokens}; +use crate::widgets; /// The themes each card offers. One of each today; a new theme is one more row here. pub const LIGHT_THEMES: &[(ThemeKind, &str)] = &[(ThemeKind::Light, "Light gray")]; @@ -103,7 +104,7 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The ui.add_space(4.0); for (kind, label) in themes { // One theme per card today, so it is always the chosen one. - let _ = ui.radio(*kind == shown, tl!(label)); + let _ = ui.add(widgets::radio(*kind == shown, tl!(label))); } }); } diff --git a/crates/ui-egui/src/bulk_fields.rs b/crates/ui-egui/src/bulk_fields.rs index 480ace703..3101724cf 100644 --- a/crates/ui-egui/src/bulk_fields.rs +++ b/crates/ui-egui/src/bulk_fields.rs @@ -203,7 +203,7 @@ impl Draft { } fn row(ui: &mut egui::Ui, label: &str, c: &mut Change, edit: impl FnOnce(&mut egui::Ui, &mut T)) { - ui.checkbox(&mut c.apply, tl!(label)); + ui.add(widgets::checkbox(&mut c.apply, tl!(label))); // Colour swatches contain their own grid; give every property's editor a separate // namespace so border and fill controls do not share widget identities. ui.push_id(label, |ui| ui.add_enabled_ui(c.apply, |ui| edit(ui, &mut c.value))); @@ -248,7 +248,7 @@ fn bool_row(ui: &mut egui::Ui, label: &str, c: &mut Change) { fn color(ui: &mut egui::Ui, value: &mut Option<[f64; 3]>) { ui.horizontal(|ui| { let mut none = value.is_none(); - if ui.checkbox(&mut none, tl!("No color")).changed() { + if ui.add(widgets::checkbox(&mut none, tl!("No color"))).changed() { *value = if none { None } else { Some([0.0; 3]) }; } if let Some(c) = crate::comments::swatch_grid(ui, *value) { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index adb67fb52..c8bf4a870 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3423,8 +3423,8 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: } let opts = icons::button(ui, "settings-2", 26.0, find.case_sensitive || find.whole_words, tl!("Find options")); egui::Popup::menu(&opts).show(|ui| { - let a = ui.checkbox(&mut find.whole_words, tl!("Whole words only")).changed(); - let b = ui.checkbox(&mut find.case_sensitive, tl!("Case-sensitive")).changed(); + let a = ui.add(widgets::checkbox(&mut find.whole_words, tl!("Whole words only"))).changed(); + let b = ui.add(widgets::checkbox(&mut find.case_sensitive, tl!("Case-sensitive"))).changed(); if a || b { // Search again with the new options. find.case_query.clear(); @@ -4050,7 +4050,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool // outside the page image, including on very tall or wide pages. let mut checked = view.selected.contains(&i); let check_rect = Rect::from_min_size(c.min + vec2(8.0, 8.0), Vec2::splat(20.0)); - let check = ui.push_id(("org-check", i), |ui| ui.put(check_rect, egui::Checkbox::without_text(&mut checked))).inner; + let check = ui.push_id(("org-check", i), |ui| ui.put(check_rect, widgets::checkbox_without_text(&mut checked))).inner; let label = crate::i18n::fmt(tl!("Select page {label}"), &[("label", &p.label)]); check.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label.clone())); if check.on_hover_text(label).changed() { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 58cf8ccd8..8036e0cf3 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -206,7 +206,7 @@ fn theme_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { (ThemePreference::Light, "view.theme.light", "Light gray"), (ThemePreference::Dark, "view.theme.dark", "Dark gray"), ] { - if ui.radio(app.theme_preference == preference, tl!(label)).clicked() { + if ui.add(widgets::radio(app.theme_preference == preference, tl!(label))).clicked() { app.execute(command); ui.close(); } @@ -467,7 +467,7 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { (RightPanel::Search, tl!("Search")), (RightPanel::Compare, tl!("Compare")), ] { - if ui.radio(app.right == Some(p), tl!(label)).clicked() { + if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() { app.choose_right_panel(Some(p)); } } @@ -595,18 +595,18 @@ fn page_display_menu(ui: &mut egui::Ui, view: &DocView) -> Option<&'static str> ui.label(egui::RichText::new(tl!("Page display")).color(t.text_faint).small()); let mut picked = None; for l in PageLayout::ORDER { - if ui.radio(view.layout == l, tl!(l.label())).clicked() { + if ui.add(widgets::radio(view.layout == l, tl!(l.label()))).clicked() { picked = Some(l.command()); } } ui.separator(); // Continuous scrolling is an independent checkbox over the base layout, as in Acrobat (#752). let mut continuous = view.continuous(); - if ui.add(egui::Checkbox::new(&mut continuous, tl!("Continuous scrolling"))).changed() { + if ui.add(widgets::checkbox(&mut continuous, tl!("Continuous scrolling"))).changed() { picked = Some("view.continuous"); } let mut cover = view.cover; - if ui.add_enabled(view.cover_applies(), egui::Checkbox::new(&mut cover, tl!("Show cover page in two-page view"))).changed() { + if ui.add_enabled(view.cover_applies(), widgets::checkbox(&mut cover, tl!("Show cover page in two-page view"))).changed() { picked = Some("view.layout.cover"); } if picked.is_some() { diff --git a/crates/ui-egui/src/comment_props.rs b/crates/ui-egui/src/comment_props.rs index a01bb94a2..e6ac0ba16 100644 --- a/crates/ui-egui/src/comment_props.rs +++ b/crates/ui-egui/src/comment_props.rs @@ -260,7 +260,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo } } ui.add_space(12.0); - ui.checkbox(&mut d.edited.locked, tl!("Locked")); + ui.add(widgets::checkbox(&mut d.edited.locked, tl!("Locked"))); let (mut apply, mut cancel) = (false, false); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if widgets::pill_button(ui, tl!("OK"), true).clicked() { diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 5182fadcf..e89c7de65 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -13,6 +13,7 @@ use crate::comments::{CommentPrefs, CommentTool, SortBy, color32, status_badge}; use crate::icons; use crate::panels::Nav; use crate::theme::{self, Tokens}; +use crate::widgets; /// Accent bar of the selected card (11-visual-spec §3: `#0165DD`). const CARD_ACCENT: Color32 = Color32::from_rgb(0x01, 0x65, 0xDD); @@ -510,7 +511,7 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV (SortBy::Type, tl!("Type")), (SortBy::Color, tl!("Colour")), ] { - if ui.radio(cv.sort == s, label).clicked() { + if ui.add(widgets::radio(cv.sort == s, label)).clicked() { cv.sort = s; ui.close(); } @@ -533,7 +534,7 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV ui.set_min_width(200.0); let toggle = |ui: &mut egui::Ui, list: &mut Vec, value: &str, label: &str| { let mut shown = !list.iter().any(|x| x == value); - if ui.checkbox(&mut shown, label).changed() { + if ui.add(widgets::checkbox(&mut shown, label)).changed() { if shown { list.retain(|x| x != value); } else { diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index fedd2d64c..a12cec8b9 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use crate::PdfCraftApp; +use crate::widgets; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ResolutionChoice { @@ -24,10 +25,10 @@ pub(crate) fn image_import_body(ui: &mut egui::Ui, app: &mut PdfCraftApp) -> (bo let Some(draft) = app.image_import.as_mut() else { return (false, true) }; ui.label(crate::i18n::fmt(tl!("Selected image files: {count}"), &[("count", &draft.images.len().to_string())])); ui.add_space(8.0); - ui.radio_value(&mut draft.choice, ResolutionChoice::Image, tl!("Use image resolution")); + widgets::radio_value(ui, &mut draft.choice, ResolutionChoice::Image, tl!("Use image resolution")); ui.label(tl!("Use each image's embedded DPI; use 72 DPI when it is absent.")); - ui.radio_value(&mut draft.choice, ResolutionChoice::Default72, tl!("Use 72 DPI (one point per pixel)")); - ui.radio_value(&mut draft.choice, ResolutionChoice::Custom, tl!("Use custom DPI")); + widgets::radio_value(ui, &mut draft.choice, ResolutionChoice::Default72, tl!("Use 72 DPI (one point per pixel)")); + widgets::radio_value(ui, &mut draft.choice, ResolutionChoice::Custom, tl!("Use custom DPI")); ui.add_enabled(draft.choice == ResolutionChoice::Custom, egui::DragValue::new(&mut draft.dpi).range(1.0..=1200.0).suffix(" DPI")); ui.label(tl!("DPI sets the printed page size without resampling the image.")); ui.add_space(12.0); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bab565fd7..89040f828 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -223,13 +223,13 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.label(""); ui.add_enabled_ui(editable, |ui| { ui.vertical(|ui| { - ui.checkbox(&mut v.fit_window, tl!("Resize window to initial page")); - ui.checkbox(&mut v.center_window, tl!("Center window on screen")); - ui.checkbox(&mut v.full_screen, tl!("Open in Full Screen mode")); + ui.add(widgets::checkbox(&mut v.fit_window, tl!("Resize window to initial page"))); + ui.add(widgets::checkbox(&mut v.center_window, tl!("Center window on screen"))); + ui.add(widgets::checkbox(&mut v.full_screen, tl!("Open in Full Screen mode"))); ui.horizontal(|ui| { ui.label(tl!("Show:")); - ui.radio_value(&mut v.display_title, false, tl!("File Name")); - ui.radio_value(&mut v.display_title, true, tl!("Document Title")); + widgets::radio_value(ui, &mut v.display_title, false, tl!("File Name")); + widgets::radio_value(ui, &mut v.display_title, true, tl!("Document Title")); }); }); }); @@ -239,9 +239,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.label(""); ui.add_enabled_ui(editable, |ui| { ui.vertical(|ui| { - ui.checkbox(&mut v.hide_menubar, tl!("Hide menu bar")); - ui.checkbox(&mut v.hide_toolbar, tl!("Hide toolbars")); - ui.checkbox(&mut v.hide_window_ui, tl!("Hide window controls")); + ui.add(widgets::checkbox(&mut v.hide_menubar, tl!("Hide menu bar"))); + ui.add(widgets::checkbox(&mut v.hide_toolbar, tl!("Hide toolbars"))); + ui.add(widgets::checkbox(&mut v.hide_window_ui, tl!("Hide window controls"))); }); }); ui.end_row(); @@ -359,8 +359,8 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(tl!("Binding")).color(t.text_muted)); ui.add_enabled_ui(editable, |ui| { ui.horizontal(|ui| { - ui.radio_value(&mut v.right_to_left, false, tl!("Left Edge")); - ui.radio_value(&mut v.right_to_left, true, tl!("Right Edge")); + widgets::radio_value(ui, &mut v.right_to_left, false, tl!("Left Edge")); + widgets::radio_value(ui, &mut v.right_to_left, true, tl!("Right Edge")); }); }); ui.end_row(); @@ -403,7 +403,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { if selected.is_empty() && draft.mode == M::Selection { draft.mode = M::Pages; } - ui.radio_value(&mut draft.mode, M::Pages, tl!("Number of pages")); + widgets::radio_value(ui, &mut draft.mode, M::Pages, tl!("Number of pages")); ui.add_enabled_ui(draft.mode == M::Pages, |ui| { ui.horizontal(|ui| { ui.add_space(24.0); @@ -411,7 +411,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add(egui::DragValue::new(&mut draft.every).range(1..=n.max(1))); }); }); - ui.radio_value(&mut draft.mode, M::Size, tl!("File size")); + widgets::radio_value(ui, &mut draft.mode, M::Size, tl!("File size")); ui.add_enabled_ui(draft.mode == M::Size, |ui| { ui.horizontal(|ui| { ui.add_space(24.0); @@ -420,14 +420,15 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { }); }); ui.add_enabled_ui(!marks.is_empty(), |ui| { - ui.radio_value( + widgets::radio_value( + ui, &mut draft.mode, M::Bookmarks, crate::i18n::fmt(tl!("Top-level bookmarks ({n})"), &[("n", &marks.len().to_string())]), ) }); ui.add_enabled_ui(!selected.is_empty(), |ui| { - ui.radio_value(&mut draft.mode, M::Selection, tl!("Before each selected page (select pages in Organize)")) + widgets::radio_value(ui, &mut draft.mode, M::Selection, tl!("Before each selected page (select pages in Organize)")) }); let plan = match draft.mode { M::Pages => crate::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(draft.every)), @@ -570,8 +571,8 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let l = ui.label(tl!("File name")); ui.add(egui::TextEdit::singleline(&mut app.extract_draft.name).desired_width(240.0)).labelled_by(l.id); }); - ui.checkbox(&mut app.extract_draft.delete, tl!("Delete pages after extracting")); - ui.checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files")); + ui.add(widgets::checkbox(&mut app.extract_draft.delete, tl!("Delete pages after extracting"))); + 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() { @@ -606,10 +607,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.end_row(); ui.label(tl!("Pages:")); ui.vertical(|ui| { - ui.radio_value(&mut d.which, 0, tl!("All")); - ui.radio_value(&mut d.which, 1, tl!("Selection")); + widgets::radio_value(ui, &mut d.which, 0, tl!("All")); + widgets::radio_value(ui, &mut d.which, 1, tl!("Selection")); ui.horizontal(|ui| { - ui.radio_value(&mut d.which, 2, tl!("From")); + widgets::radio_value(ui, &mut d.which, 2, tl!("From")); ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)); ui.label(tl!("to")); ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)); @@ -666,9 +667,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(tl!("Duplicate Field")).font(theme::semibold(18.0))); ui.add_space(8.0); ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)])); - ui.radio_value(&mut d.all, true, tl!("All pages")); + widgets::radio_value(ui, &mut d.all, true, tl!("All pages")); ui.horizontal(|ui| { - ui.radio_value(&mut d.all, false, tl!("From")); + widgets::radio_value(ui, &mut d.all, false, tl!("From")); ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n)); ui.label(tl!("to")); ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)); @@ -734,7 +735,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(tl!("Summarize Options")).font(theme::semibold(18.0))); ui.add_space(8.0); ui.label(tl!("Choose a layout:")); - let _ = ui.radio(true, tl!("Comments only")); + let _ = ui.add(widgets::radio(true, tl!("Comments only"))); ui.add_space(6.0); ui.horizontal(|ui| { ui.label(tl!("Sort comments by:")); diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index b34a9e6ce..12068f14f 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -12,6 +12,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; use crate::theme::Tokens; +use crate::widgets; const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); /// Background of the in-place text field editor, the same in every theme. @@ -425,7 +426,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f let on = if multi { fx.picked.contains(export) } else { f.value.first() == Some(export) }; if multi { let mut c = on; - if ui.checkbox(&mut c, display).changed() { + if ui.add(widgets::checkbox(&mut c, display)).changed() { if c { fx.picked.push(export.clone()); } else { diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index fb7d64a43..2412651ad 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -291,7 +291,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok (crate::Mode::Convert, "Convert"), (crate::Mode::Sign, "E-Sign"), ] { - ui.radio_value(&mut app.default_mode, mode, tl!(label)); + widgets::radio_value(ui, &mut app.default_mode, mode, tl!(label)); } }); ui.label( @@ -303,16 +303,16 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ui.horizontal(|ui| { ui.label(tl!("Default page display")); for l in crate::canvas::PageLayout::ORDER { - ui.radio_value(&mut defaults.layout, l, tl!(l.label())); + widgets::radio_value(ui, &mut defaults.layout, l, tl!(l.label())); } - ui.checkbox(&mut defaults.continuous, tl!("Continuous scrolling")); + ui.add(widgets::checkbox(&mut defaults.continuous, tl!("Continuous scrolling"))); }); ui.horizontal(|ui| { use crate::canvas::Fit; ui.label(tl!("Default zoom")); - ui.radio_value(&mut defaults.fit, Fit::Width, tl!("Fit to width")); - ui.radio_value(&mut defaults.fit, Fit::Page, tl!("Zoom to page level")); - ui.radio_value(&mut defaults.fit, Fit::None, tl!("Custom")); + widgets::radio_value(ui, &mut defaults.fit, Fit::Width, tl!("Fit to width")); + widgets::radio_value(ui, &mut defaults.fit, Fit::Page, tl!("Zoom to page level")); + widgets::radio_value(ui, &mut defaults.fit, Fit::None, tl!("Custom")); // Editing the percentage selects Custom. let mut percent = defaults.zoom * 100.0; if ui.add(egui::DragValue::new(&mut percent).range(8.0..=6400.0).max_decimals(0).suffix("%")).changed() { @@ -327,20 +327,20 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ui.horizontal(|ui| { ui.label(tl!("Units")); for u in crate::units::Unit::ALL { - ui.radio_value(&mut app.units, u, u.label()); + widgets::radio_value(ui, &mut app.units, u, u.label()); } }); ui.label(egui::RichText::new(tl!("For page sizes and margins.")).small().color(t.text_muted)); // The web build has no file paths to reopen. #[cfg(not(target_arch = "wasm32"))] - ui.checkbox(&mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed")); + ui.add(widgets::checkbox(&mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed"))); // The web build has no files on disk to watch. #[cfg(not(target_arch = "wasm32"))] - ui.checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk")); + ui.add(widgets::checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk"))); ui.horizontal(|ui| { ui.label(tl!("Mouse wheel scroll speed")); for speed in crate::scroll_speed::ScrollSpeed::ALL { - ui.radio_value(&mut app.scroll_speed, speed, tl_ctx!("scroll speed", speed.label())); + widgets::radio_value(ui, &mut app.scroll_speed, speed, tl_ctx!("scroll speed", speed.label())); } }); ui.add_space(8.0); @@ -352,7 +352,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok }); ui.add_space(8.0); ui.label(egui::RichText::new(tl!("Fill & Sign")).font(theme::semibold(13.0))); - ui.checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving")); + ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"))); ui.label( egui::RichText::new(tl!("Text, marks and signatures become part of the page. Other comments stay editable.")).small().color(t.text_muted), ); @@ -363,7 +363,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { ui.set_width(ui.available_width()); let mut on = app.session.javascript(); - if ui.checkbox(&mut on, tl!("Enable Acrobat JavaScript")).changed() { + if ui.add(widgets::checkbox(&mut on, tl!("Enable Acrobat JavaScript"))).changed() { app.session.set_javascript(on); } ui.label( diff --git a/crates/ui-egui/src/link_ui.rs b/crates/ui-egui/src/link_ui.rs index 1079a4680..8dc91cf7f 100644 --- a/crates/ui-egui/src/link_ui.rs +++ b/crates/ui-egui/src/link_ui.rs @@ -249,14 +249,14 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token }); ui.add_space(8.0); widgets::section_title(ui, tl!("Link Action")); - ui.radio_value(&mut d.web, true, tl!("Open a web page")); + widgets::radio_value(ui, &mut d.web, true, tl!("Open a web page")); ui.add_enabled_ui(d.web, |ui| { ui.horizontal(|ui| { ui.add_space(24.0); ui.add(crate::widgets::line(&mut d.url).desired_width(360.0).hint_text("https://example.org")); }); }); - ui.radio_value(&mut d.web, false, tl!("Go to a page view")); + widgets::radio_value(ui, &mut d.web, false, tl!("Go to a page view")); ui.add_enabled_ui(!d.web, |ui| { ui.horizontal(|ui| { ui.add_space(24.0); diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index 2bf7d6f79..84214fc2f 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -49,8 +49,8 @@ impl PageRange { pub(crate) fn ui(&mut self, ui: &mut egui::Ui, count: usize) { ui.horizontal(|ui| { - ui.radio_value(&mut self.all, true, tl!("All pages")); - ui.radio_value(&mut self.all, false, tl!("Pages from")); + widgets::radio_value(ui, &mut self.all, true, tl!("All pages")); + widgets::radio_value(ui, &mut self.all, false, tl!("Pages from")); ui.add_enabled(!self.all, egui::DragValue::new(&mut self.from).range(1..=count.max(1))); ui.label(tl!("to")); if self.to == 1 && self.from == 1 { @@ -164,7 +164,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.label(tl!("Font: Helvetica")); ui.label(tl!("Size")); ui.add(egui::DragValue::new(&mut d.hf.font_size).range(4.0..=72.0).speed(0.5)); - ui.checkbox(&mut d.hf.underline, tl!("Underline")); + ui.add(widgets::checkbox(&mut d.hf.underline, tl!("Underline"))); color_button(ui, &mut d.hf.color); ui.add_space(16.0); ui.label(format!("{} ({})", tl!("Margins"), units.label())); @@ -228,8 +228,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M MarkKind::Watermark => { ui.horizontal(|ui| { ui.label(tl!("Source")); - ui.radio_value(&mut d.use_file, false, tl!("Text")); - ui.radio_value(&mut d.use_file, true, tl!("File")); + widgets::radio_value(ui, &mut d.use_file, false, tl!("Text")); + widgets::radio_value(ui, &mut d.use_file, true, tl!("File")); }); if d.use_file { browse |= file_source(ui, d, MarkKind::Watermark); @@ -243,7 +243,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ); }); ui.horizontal(|ui| { - ui.add_enabled_ui(!d.use_file, |ui| ui.checkbox(&mut d.fit, tl!("Fit to page"))); + ui.add_enabled_ui(!d.use_file, |ui| ui.add(widgets::checkbox(&mut d.fit, tl!("Fit to page")))); ui.add_enabled_ui(!d.fit, |ui| { ui.label(tl!("Size")); if d.wm.font_size == 0.0 { @@ -256,7 +256,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.horizontal(|ui| { ui.label(tl!("Rotation")); for r in [-45.0, 0.0, 45.0] { - ui.radio_value(&mut d.wm.rotation, r, if r == 0.0 { tl!("None").to_string() } else { format!("{r}°") }); + widgets::radio_value(ui, &mut d.wm.rotation, r, if r == 0.0 { tl!("None").to_string() } else { format!("{r}°") }); } ui.add(egui::DragValue::new(&mut d.wm.rotation).range(-180.0..=180.0).suffix("°")); }); @@ -269,8 +269,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M }); ui.horizontal(|ui| { ui.label(tl!("Location")); - ui.radio_value(&mut d.wm.behind, true, tl!("Appear behind page")); - ui.radio_value(&mut d.wm.behind, false, tl!("Appear on top of page")); + widgets::radio_value(ui, &mut d.wm.behind, true, tl!("Appear behind page")); + widgets::radio_value(ui, &mut d.wm.behind, false, tl!("Appear on top of page")); }); ui.horizontal(|ui| { ui.label(tl!("Offset from centre")); @@ -284,10 +284,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } MarkKind::Background => { ui.horizontal(|ui| { - ui.radio_value(&mut d.use_file, false, tl!("From colour")); + widgets::radio_value(ui, &mut d.use_file, false, tl!("From colour")); ui.add_enabled_ui(!d.use_file, |ui| color_button(ui, &mut d.bg.color)); ui.add_space(12.0); - ui.radio_value(&mut d.use_file, true, tl!("File")); + widgets::radio_value(ui, &mut d.use_file, true, tl!("File")); }); if d.use_file { browse |= file_source(ui, d, MarkKind::Background); diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index 1a1f83c37..f031a8d07 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -1,5 +1,5 @@ //! Measure objects: page-space clicks, live readings and persistent drawing scales. -use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, theme::Tokens}; +use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, theme::Tokens, widgets}; use egui::{Color32, Pos2, Stroke}; use pdfcraft_engine::{ Document, Edit, Style, @@ -338,15 +338,15 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.add(crate::widgets::line(&mut state.label).desired_width(130.0).char_limit(128)); }); ui.separator(); - ui.checkbox(&mut state.snap_enabled, tl!("Snap to drawing")); + ui.add(widgets::checkbox(&mut state.snap_enabled, tl!("Snap to drawing"))); if state.snap_enabled { ui.horizontal(|ui| { - ui.checkbox(&mut state.snaps.endpoints, tl!("Endpoints")); - ui.checkbox(&mut state.snaps.midpoints, tl!("Midpoints")); + ui.add(widgets::checkbox(&mut state.snaps.endpoints, tl!("Endpoints"))); + ui.add(widgets::checkbox(&mut state.snaps.midpoints, tl!("Midpoints"))); }); ui.horizontal(|ui| { - ui.checkbox(&mut state.snaps.intersections, tl!("Intersections")); - ui.checkbox(&mut state.snaps.paths, tl!("Paths")); + ui.add(widgets::checkbox(&mut state.snaps.intersections, tl!("Intersections"))); + ui.add(widgets::checkbox(&mut state.snaps.paths, tl!("Paths"))); }); ui.add(egui::Slider::new(&mut state.sensitivity, 1.0..=20.0).text(tl!("Sensitivity"))); } @@ -365,7 +365,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { if ui.button(tl!("Calibrate from two points")).clicked() { tool = Some(Tool::Calibrate); } - ui.checkbox(&mut state.whole_page, tl!("Entire page")); + ui.add(widgets::checkbox(&mut state.whole_page, tl!("Entire page"))); if !state.whole_page { ui.label(tl!("Viewport rectangle in page points (x, y, width, height)")); for (i, name) in ["X", "Y", "Width", "Height"].iter().enumerate() { diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index f6d38e1e8..5bde1c956 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -75,10 +75,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.add_space(8.0); }; group(ui, "Pages", &mut |ui| { - ui.radio_value(&mut d.pages, OcrPages::All, tl!("All pages")); - ui.radio_value(&mut d.pages, OcrPages::Current, tl!("Current page")); + widgets::radio_value(ui, &mut d.pages, OcrPages::All, tl!("All pages")); + widgets::radio_value(ui, &mut d.pages, OcrPages::Current, tl!("Current page")); ui.horizontal(|ui| { - ui.radio_value(&mut d.pages, OcrPages::Range, tl!("From")); + widgets::radio_value(ui, &mut d.pages, OcrPages::Range, tl!("From")); let on = d.pages == OcrPages::Range; ui.add_enabled(on, egui::DragValue::new(&mut d.from).range(1..=pages)); ui.label(tl!("to")); diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 4d6c843eb..a35f02977 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -107,7 +107,7 @@ fn summary(r: &OptimizeReport) -> String { fn image_row(ui: &mut egui::Ui, id: &str, title: &str, s: &mut ImageSettings) { ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(13.0))); ui.horizontal(|ui| { - ui.checkbox(&mut s.downsample, tl!("Bicubic downsampling to")); + ui.add(widgets::checkbox(&mut s.downsample, tl!("Bicubic downsampling to"))); ui.add_enabled(s.downsample, egui::DragValue::new(&mut s.target_ppi).range(9.0..=2400.0).suffix(" ppi")); ui.label(tl!("for images above")); ui.add_enabled(s.downsample, egui::DragValue::new(&mut s.above_ppi).range(9.0..=2400.0).suffix(" ppi")); @@ -144,7 +144,7 @@ fn image_row(ui: &mut egui::Ui, id: &str, title: &str, s: &mut ImageSettings) { fn discard_box(ui: &mut egui::Ui, list: &mut Vec, h: Hidden, label: &str) { let mut on = list.contains(&h); - if ui.checkbox(&mut on, tl!(label)).changed() { + if ui.add(widgets::checkbox(&mut on, tl!(label))).changed() { if on { list.push(h); } else { @@ -184,10 +184,10 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo } OptimizeTab::DiscardObjects => { discard_box(ui, &mut d.discard, Hidden::LinksActionsScripts, "Discard all links, actions and JavaScript"); - ui.checkbox(&mut s.discard_alternate_images, tl!("Discard alternate images")); - ui.checkbox(&mut s.discard_thumbnails, tl!("Discard embedded page thumbnails")); - ui.checkbox(&mut s.discard_tags, tl!("Discard document tags")); - ui.checkbox(&mut s.discard_print_settings, tl!("Discard embedded print settings")); + ui.add(widgets::checkbox(&mut s.discard_alternate_images, tl!("Discard alternate images"))); + ui.add(widgets::checkbox(&mut s.discard_thumbnails, tl!("Discard embedded page thumbnails"))); + ui.add(widgets::checkbox(&mut s.discard_tags, tl!("Discard document tags"))); + ui.add(widgets::checkbox(&mut s.discard_print_settings, tl!("Discard embedded print settings"))); discard_box(ui, &mut d.discard, Hidden::Bookmarks, "Discard bookmarks"); discard_box(ui, &mut d.discard, Hidden::FormFields, "Flatten form fields"); discard_box(ui, &mut d.discard, Hidden::HiddenLayers, "Discard hidden layer content"); @@ -200,11 +200,11 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo discard_box(ui, &mut d.discard, Hidden::HiddenText, "Discard hidden text"); } OptimizeTab::CleanUp => { - ui.checkbox(&mut s.flate_unencoded, tl!("Use Flate to encode streams that are not encoded")); - ui.checkbox(&mut s.remove_invalid_links, tl!("Remove invalid links and bookmarks")); - ui.checkbox(&mut s.remove_unreferenced_dests, tl!("Remove unreferenced named destinations")); - ui.add_enabled(false, egui::Checkbox::new(&mut true, tl!("Compress document structure (object streams)"))); - ui.add_enabled(false, egui::Checkbox::new(&mut true, tl!("Remove unused objects and merge identical ones"))); + ui.add(widgets::checkbox(&mut s.flate_unencoded, tl!("Use Flate to encode streams that are not encoded"))); + ui.add(widgets::checkbox(&mut s.remove_invalid_links, tl!("Remove invalid links and bookmarks"))); + ui.add(widgets::checkbox(&mut s.remove_unreferenced_dests, tl!("Remove unreferenced named destinations"))); + ui.add_enabled(false, widgets::checkbox(&mut true, tl!("Compress document structure (object streams)"))); + ui.add_enabled(false, widgets::checkbox(&mut true, tl!("Remove unused objects and merge identical ones"))); } } ui.add_space(12.0); diff --git a/crates/ui-egui/src/pageboxes.rs b/crates/ui-egui/src/pageboxes.rs index fb998805e..1522d0003 100644 --- a/crates/ui-egui/src/pageboxes.rs +++ b/crates/ui-egui/src/pageboxes.rs @@ -135,15 +135,20 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.end_row(); ui.label(tl!("Pages")); ui.vertical(|ui| { - ui.radio_value(&mut d.range, Range::All, tl!("All")); - ui.radio_value(&mut d.range, Range::Current, crate::i18n::fmt(tl!("Current page ({n})"), &[("n", &(current + 1).to_string())])); + widgets::radio_value(ui, &mut d.range, Range::All, tl!("All")); + widgets::radio_value( + ui, + &mut d.range, + Range::Current, + crate::i18n::fmt(tl!("Current page ({n})"), &[("n", &(current + 1).to_string())]), + ); let (mut a, mut b) = match d.range { Range::Pages(a, b) => (a, b), _ => (1, count), }; ui.horizontal(|ui| { let on = matches!(d.range, Range::Pages(..)); - if ui.radio(on, tl!("From")).clicked() { + if ui.add(widgets::radio(on, tl!("From"))).clicked() { d.range = Range::Pages(a, b); } let ra = ui.add(egui::DragValue::new(&mut a).range(1..=count)); diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index a0ebc2111..37747f14c 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -12,6 +12,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; use crate::theme; +use crate::widgets; const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); /// Acrobat's field fill in Prepare Form (light blue). @@ -918,7 +919,7 @@ const OPTION_FLAGS: [u32; 10] = { /// A check box for a `/Ff` bit (`inverted`: checked when the bit is clear). fn flag_box(ui: &mut egui::Ui, flags: &mut u32, bit: u32, inverted: bool, label: &str) { let mut on = (*flags & bit != 0) != inverted; - if ui.checkbox(&mut on, label).changed() { + if ui.add(widgets::checkbox(&mut on, label)).changed() { if on != inverted { *flags |= bit; } else { @@ -965,15 +966,15 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke }); ui.add_space(12.0); widgets::section_title(ui, tl!("Common Properties")); - ui.checkbox(&mut d.read_only, tl!("Read Only")); - ui.checkbox(&mut d.required, tl!("Required")); + ui.add(widgets::checkbox(&mut d.read_only, tl!("Read Only"))); + ui.add(widgets::checkbox(&mut d.required, tl!("Required"))); } FieldTab::Appearance => { egui::Grid::new("field-appearance").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { ui.label(tl!("Font Size:")); let mut auto = d.font_size == 0.0; ui.horizontal(|ui| { - if ui.checkbox(&mut auto, tl!("Auto")).changed() { + if ui.add(widgets::checkbox(&mut auto, tl!("Auto"))).changed() { d.font_size = if auto { 0.0 } else { 12.0 }; } ui.add_enabled(!auto, egui::DragValue::new(&mut d.font_size).range(2.0..=100.0).speed(0.25).suffix(" pt")); @@ -1004,7 +1005,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.push_id(("field-color", index), |ui| { ui.horizontal(|ui| { let mut none = slot.is_none(); - if ui.checkbox(&mut none, tl!("No color")).changed() { + if ui.add(widgets::checkbox(&mut none, tl!("No color"))).changed() { *slot = if none { None } else { Some([0.0; 3]) }; } if let Some(c) = crate::comments::swatch_grid(ui, *slot) { @@ -1094,11 +1095,11 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.end_row(); }); ui.add_space(6.0); - ui.checkbox(&mut d.multiline, tl!("Multi-line")); + ui.add(widgets::checkbox(&mut d.multiline, tl!("Multi-line"))); flag_box(ui, &mut d.flags, ff::DO_NOT_SCROLL, true, tl!("Scroll long text")); flag_box(ui, &mut d.flags, ff::RICH_TEXT, false, tl!("Allow Rich Text Formatting")); ui.horizontal(|ui| { - ui.checkbox(&mut d.limit, tl!("Limit of")); + ui.add(widgets::checkbox(&mut d.limit, tl!("Limit of"))); ui.add_enabled(d.limit, egui::DragValue::new(&mut d.max_len).range(0..=10_000)); ui.label(tl!("characters")); }); @@ -1126,7 +1127,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke let mut checked = !d.default.is_empty() && d.default == on; let label = if d.kind == FormFieldKind::CheckBox { tl!("Check box is checked by default") } else { tl!("Button is checked by default") }; - if ui.checkbox(&mut checked, label).changed() { + if ui.add(widgets::checkbox(&mut checked, label)).changed() { d.default = if checked { on } else { String::new() }; } if let Some(style) = d.check_style.as_mut() { @@ -1210,7 +1211,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke } }); ui.add_space(8.0); - ui.checkbox(&mut d.locked, tl!("Locked")); + ui.add(widgets::checkbox(&mut d.locked, tl!("Locked"))); ui.add_space(6.0); let (mut apply, mut cancel) = (enter, false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -1323,7 +1324,7 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { ui.selectable_value(currency, c.to_string(), if c.is_empty() { tl!("None") } else { c }); } }); - ui.checkbox(prepend, tl!("Before the number")); + ui.add(widgets::checkbox(prepend, tl!("Before the number"))); }); ui.end_row(); ui.label(tl!("Negative number style:")); @@ -1406,8 +1407,8 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) { let mut ranged = matches!(d.validate, Validate::Range { .. }); - ui.radio_value(&mut ranged, false, tl!("Field value is not validated")); - ui.radio_value(&mut ranged, true, tl!("Field value is in range:")); + widgets::radio_value(ui, &mut ranged, false, tl!("Field value is not validated")); + widgets::radio_value(ui, &mut ranged, true, tl!("Field value is in range:")); if !ranged { d.validate = Validate::None; return; @@ -1418,12 +1419,12 @@ fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) { if let Validate::Range { min, max } = &mut d.validate { ui.horizontal(|ui| { let mut has = min.is_some(); - ui.checkbox(&mut has, tl!("From:")); + ui.add(widgets::checkbox(&mut has, tl!("From:"))); let mut v = min.unwrap_or(0.0); ui.add_enabled(has, egui::DragValue::new(&mut v).speed(1.0)); *min = has.then_some(v); let mut has = max.is_some(); - ui.checkbox(&mut has, tl!("To:")); + ui.add(widgets::checkbox(&mut has, tl!("To:"))); let mut v = max.unwrap_or(100.0); ui.add_enabled(has, egui::DragValue::new(&mut v).speed(1.0)); *max = has.then_some(v); @@ -1521,8 +1522,8 @@ fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens Calculate::Notation(_) => Mode::Notation, }; let mut m = mode; - ui.radio_value(&mut m, Mode::None, tl!("Value is not calculated")); - ui.radio_value(&mut m, Mode::Simple, tl!("Value is the")); + widgets::radio_value(ui, &mut m, Mode::None, tl!("Value is not calculated")); + widgets::radio_value(ui, &mut m, Mode::Simple, tl!("Value is the")); if m != mode { d.calculate = match m { Mode::None => Calculate::None, @@ -1543,7 +1544,7 @@ fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { for name in &d.others { let mut on = fields.contains(name); - if ui.checkbox(&mut on, name).changed() { + if ui.add(widgets::checkbox(&mut on, name)).changed() { if on { fields.push(name.clone()); } else { @@ -1557,7 +1558,9 @@ fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens Calculate::Notation(_) => Mode::Notation, _ => m, }; - if ui.radio_value(&mut m2, Mode::Notation, tl!("Simplified field notation:")).clicked() && !matches!(d.calculate, Calculate::Notation(_)) { + if widgets::radio_value(ui, &mut m2, Mode::Notation, tl!("Simplified field notation:")).clicked() + && !matches!(d.calculate, Calculate::Notation(_)) + { d.calculate = Calculate::Notation(String::new()); } if let Calculate::Notation(expr) = &mut d.calculate { diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 213de3cf4..2f1924d1a 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -591,8 +591,8 @@ pub(crate) fn body( ui.label(tl!("Copies:")); ui.horizontal(|ui| { ui.add(egui::DragValue::new(&mut d.copies).range(1..=999)); - ui.checkbox(&mut d.collate, tl!("Collate")); - ui.checkbox(&mut d.grayscale, tl!("Print in grayscale")); + ui.add(widgets::checkbox(&mut d.collate, tl!("Collate"))); + ui.add(widgets::checkbox(&mut d.grayscale, tl!("Print in grayscale"))); }); ui.end_row(); ui.label(tl!("Two-sided:")); @@ -641,9 +641,9 @@ pub(crate) fn body( ui.add_space(6.0); widgets::section_title(ui, tl!("Pages to Print")); ui.horizontal(|ui| { - ui.radio_value(&mut d.which, Which::All, tl!("All")); - ui.radio_value(&mut d.which, Which::Current, tl!("Current page")); - ui.radio_value(&mut d.which, Which::Range, tl!("Pages")); + widgets::radio_value(ui, &mut d.which, Which::All, tl!("All")); + widgets::radio_value(ui, &mut d.which, Which::Current, tl!("Current page")); + widgets::radio_value(ui, &mut d.which, Which::Range, tl!("Pages")); let r = ui.add_enabled( d.which == Which::Range, crate::widgets::line(&mut d.range).hint_text(format!("1-{}", sizes.len())).desired_width(110.0), @@ -655,7 +655,7 @@ pub(crate) fn body( // Offered only when pages were picked before the dialog opened. if !d.selected.is_empty() { let label = format!("{} ({})", tl!("Selected pages"), d.selected.len()); - ui.radio_value(&mut d.which, Which::Selected, label); + widgets::radio_value(ui, &mut d.which, Which::Selected, label); } ui.horizontal(|ui| { ui.label(tl!("More options:")); @@ -666,7 +666,7 @@ pub(crate) fn body( &[(Subset::All, tl!("All pages in range")), (Subset::Odd, tl!("Odd pages only")), (Subset::Even, tl!("Even pages only"))], 150.0, ); - ui.checkbox(&mut d.reverse, tl!("Reverse pages")); + ui.add(widgets::checkbox(&mut d.reverse, tl!("Reverse pages"))); }); ui.add_space(6.0); widgets::section_title(ui, tl!("Page Sizing & Handling")); @@ -687,13 +687,13 @@ pub(crate) fn body( match d.handling { Handling::Size => { ui.horizontal(|ui| { - ui.radio_value(&mut d.size, SizeMode::Fit, tl!("Fit")); - ui.radio_value(&mut d.size, SizeMode::Actual, tl!("Actual size")); - ui.radio_value(&mut d.size, SizeMode::Shrink, tl!("Shrink oversized pages")); + widgets::radio_value(ui, &mut d.size, SizeMode::Fit, tl!("Fit")); + widgets::radio_value(ui, &mut d.size, SizeMode::Actual, tl!("Actual size")); + widgets::radio_value(ui, &mut d.size, SizeMode::Shrink, tl!("Shrink oversized pages")); }); ui.horizontal(|ui| { let custom = matches!(d.size, SizeMode::Custom(_)); - if ui.radio(custom, tl!("Custom scale:")).clicked() { + if ui.add(widgets::radio(custom, tl!("Custom scale:"))).clicked() { d.size = SizeMode::Custom(d.custom_scale); } ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(1.0..=1000.0).suffix(" %")); @@ -705,7 +705,7 @@ pub(crate) fn body( ui.add(egui::DragValue::new(&mut d.poster_scale).range(10.0..=1000.0).suffix(" %")); ui.label(tl!("Overlap:")); ui.add(egui::DragValue::new(&mut d.overlap).range(0.0..=144.0).suffix(" pt")); - ui.checkbox(&mut d.cut_marks, tl!("Cut marks")); + ui.add(widgets::checkbox(&mut d.cut_marks, tl!("Cut marks"))); }); } Handling::Multiple => { @@ -728,8 +728,8 @@ pub(crate) fn body( ); }); ui.horizontal(|ui| { - ui.checkbox(&mut d.border, tl!("Print page border")); - ui.checkbox(&mut d.auto_rotate, tl!("Auto-rotate pages")); + ui.add(widgets::checkbox(&mut d.border, tl!("Print page border"))); + ui.add(widgets::checkbox(&mut d.auto_rotate, tl!("Auto-rotate pages"))); }); if d.order == PageOrder::CutStack { ui.label(tl!( @@ -759,9 +759,9 @@ pub(crate) fn body( ui.add_space(6.0); ui.horizontal(|ui| { ui.label(tl!("Orientation:")); - ui.radio_value(&mut d.orientation, Orientation::Auto, tl!("Auto portrait/landscape")); - ui.radio_value(&mut d.orientation, Orientation::Portrait, tl!("Portrait")); - ui.radio_value(&mut d.orientation, Orientation::Landscape, tl!("Landscape")); + widgets::radio_value(ui, &mut d.orientation, Orientation::Auto, tl!("Auto portrait/landscape")); + widgets::radio_value(ui, &mut d.orientation, Orientation::Portrait, tl!("Portrait")); + widgets::radio_value(ui, &mut d.orientation, Orientation::Landscape, tl!("Landscape")); }); ui.add_space(6.0); widgets::section_title(ui, tl!("Comments & Forms")); diff --git a/crates/ui-egui/src/protect.rs b/crates/ui-egui/src/protect.rs index 9406b0dc3..a4336d546 100644 --- a/crates/ui-egui/src/protect.rs +++ b/crates/ui-egui/src/protect.rs @@ -152,10 +152,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.end_row(); ui.label(tl!("Encrypt")); ui.vertical(|ui| { - ui.radio_value(&mut p.encrypt_metadata, true, tl!("All document contents")); + widgets::radio_value(ui, &mut p.encrypt_metadata, true, tl!("All document contents")); // Unencrypted metadata needs crypt filters (Acrobat 6.0 and later). ui.add_enabled_ui(p.algorithm != A::Rc4_40 && p.algorithm != A::Rc4_128, |ui| { - ui.radio_value(&mut p.encrypt_metadata, false, tl!("All document contents except metadata")); + widgets::radio_value(ui, &mut p.encrypt_metadata, false, tl!("All document contents except metadata")); }); }); ui.end_row(); @@ -187,8 +187,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.end_row(); ui.label(""); ui.vertical(|ui| { - ui.checkbox(&mut p.copy, tl!("Enable copying of text, images, and other content")); - ui.checkbox(&mut p.accessibility, tl!("Enable text access for screen reader devices for the visually impaired")); + ui.add(widgets::checkbox(&mut p.copy, tl!("Enable copying of text, images, and other content"))); + ui.add(widgets::checkbox(&mut p.accessibility, tl!("Enable text access for screen reader devices for the visually impaired"))); }); ui.end_row(); } diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index 4af458950..a0dc247b7 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -248,9 +248,9 @@ impl PdfCraftApp { pub(crate) fn pages_body(ui: &mut egui::Ui, d: &mut PagesDraft, pages: usize, _t: &Tokens) -> (bool, bool) { ui.label(egui::RichText::new(tl!("Mark Page Range")).font(crate::theme::semibold(18.0))); ui.add_space(8.0); - ui.radio_value(&mut d.current, true, tl!("Current page")); + widgets::radio_value(ui, &mut d.current, true, tl!("Current page")); ui.horizontal(|ui| { - ui.radio_value(&mut d.current, false, tl!("Pages from")); + widgets::radio_value(ui, &mut d.current, false, tl!("Pages from")); ui.add_enabled(!d.current, egui::DragValue::new(&mut d.from).range(1..=pages)); ui.label(tl!("to")); ui.add_enabled(!d.current, egui::DragValue::new(&mut d.to).range(1..=pages)); @@ -266,14 +266,14 @@ pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> ui.set_width(420.0); ui.label(egui::RichText::new(tl!("Find text and redact")).font(crate::theme::semibold(18.0))); ui.add_space(8.0); - ui.radio_value(&mut d.mode, SearchMode::Phrase, tl!("Single word or phrase")); + widgets::radio_value(ui, &mut d.mode, SearchMode::Phrase, tl!("Single word or phrase")); let mut enter = false; ui.add_enabled_ui(d.mode == SearchMode::Phrase, |ui| { let r = ui.add(crate::widgets::line(&mut d.text).hint_text(tl!("Text to find")).desired_width(380.0)); enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); }); ui.add_space(6.0); - ui.radio_value(&mut d.mode, SearchMode::Words, tl!("Multiple words or phrases")); + widgets::radio_value(ui, &mut d.mode, SearchMode::Words, tl!("Multiple words or phrases")); let mut import = false; ui.add_enabled_ui(d.mode == SearchMode::Words, |ui| { egui::ScrollArea::vertical().id_salt("redact-words").max_height(120.0).show(ui, |ui| { @@ -286,7 +286,7 @@ pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> }); }); ui.add_space(6.0); - ui.radio_value(&mut d.mode, SearchMode::Patterns, tl!("Patterns")); + widgets::radio_value(ui, &mut d.mode, SearchMode::Patterns, tl!("Patterns")); ui.add_enabled_ui(d.mode == SearchMode::Patterns, |ui| { egui::ComboBox::from_id_salt("redact-pattern").selected_text(tl!(d.pattern.label())).width(240.0).show_ui(ui, |ui| { for p in REDACT_PATTERNS { @@ -320,7 +320,7 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> ui.label(tl!("Redacted area fill colour:")); ui.horizontal(|ui| { let mut none = d.fill.is_none(); - if ui.checkbox(&mut none, tl!("No colour")).changed() { + if ui.add(widgets::checkbox(&mut none, tl!("No colour"))).changed() { d.fill = if none { None } else { Some([0.0, 0.0, 0.0]) }; } }); @@ -331,16 +331,16 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> } ui.end_row(); ui.label(""); - ui.checkbox(&mut d.use_overlay, tl!("Use overlay text")); + ui.add(widgets::checkbox(&mut d.use_overlay, tl!("Use overlay text"))); ui.end_row(); let on = d.use_overlay; - let l = ui.add_enabled(on, egui::RadioButton::new(!d.use_code, tl!("Custom text:"))); + let l = ui.add_enabled(on, widgets::radio(!d.use_code, tl!("Custom text:"))); if l.clicked() { d.use_code = false; } ui.add_enabled(on && !d.use_code, crate::widgets::line(&mut d.overlay).desired_width(220.0)).labelled_by(l.id); ui.end_row(); - if ui.add_enabled(on, egui::RadioButton::new(d.use_code, tl!("Redaction code:"))).clicked() { + if ui.add_enabled(on, widgets::radio(d.use_code, tl!("Redaction code:"))).clicked() { d.use_code = true; } let codes_on = on && d.use_code; @@ -361,7 +361,7 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> ui.horizontal(|ui| { for &code in row { let mut picked = d.codes.contains(&code); - if ui.add_sized([62.0, 18.0], egui::Checkbox::new(&mut picked, code)).changed() { + if ui.add_sized([62.0, 18.0], widgets::checkbox(&mut picked, code)).changed() { d.codes.retain(|c| *c != code); if picked { d.codes.push(code); @@ -386,7 +386,7 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> ui.add_enabled_ui(on, |ui| { ui.horizontal(|ui| { let mut auto = look.size <= 0.0; - if ui.checkbox(&mut auto, tl!("Auto-size text to fit redaction region")).changed() { + if ui.add(widgets::checkbox(&mut auto, tl!("Auto-size text to fit redaction region"))).changed() { look.size = if auto { 0.0 } else { 10.0 }; } if !auto { @@ -403,13 +403,13 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> }); ui.end_row(); ui.label(""); - ui.add_enabled(on, egui::Checkbox::new(&mut look.repeat, tl!("Repeat overlay text"))); + ui.add_enabled(on, widgets::checkbox(&mut look.repeat, tl!("Repeat overlay text"))); ui.end_row(); ui.label(tl!("Text alignment:")); ui.add_enabled_ui(on, |ui| { ui.horizontal(|ui| { for (a, label) in [(0u8, tl!("Left")), (1, tl!("Center")), (2, tl!("Right"))] { - ui.radio_value(&mut look.align, a, label); + widgets::radio_value(ui, &mut look.align, a, label); } }); }); @@ -480,7 +480,7 @@ pub(crate) fn hidden_body(ui: &mut egui::Ui, d: &mut HiddenDraft, t: &Tokens) -> for (h, n, on) in d.found.iter_mut() { ui.add_enabled_ui(*n > 0, |ui| { ui.horizontal(|ui| { - ui.checkbox(on, tl!(h.label())); + ui.add(widgets::checkbox(on, tl!(h.label()))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(egui::RichText::new(if *n == 0 { tl!("None found").to_string() } else { n.to_string() }).color(t.text_muted)); }); diff --git a/crates/ui-egui/src/search_ui.rs b/crates/ui-egui/src/search_ui.rs index c23968bac..630ca898d 100644 --- a/crates/ui-egui/src/search_ui.rs +++ b/crates/ui-egui/src/search_ui.rs @@ -6,6 +6,7 @@ use egui::{Color32, FontId, TextFormat}; use crate::canvas::DocView; use crate::theme::{self, Tokens}; +use crate::widgets; /// Glyphs of context shown on each side of a match. const CONTEXT: usize = 36; @@ -28,8 +29,8 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, pages: us let w = &mut ui.visuals_mut().widgets; w.inactive.bg_stroke = egui::Stroke::new(1.0, t.border); w.inactive.bg_fill = t.hover; - let a = ui.checkbox(&mut find.whole_words, tl!("Whole words only")).changed(); - let b = ui.checkbox(&mut find.case_sensitive, tl!("Case-sensitive")).changed(); + let a = ui.add(widgets::checkbox(&mut find.whole_words, tl!("Whole words only"))).changed(); + let b = ui.add(widgets::checkbox(&mut find.case_sensitive, tl!("Case-sensitive"))).changed(); if a || b { find.case_query.clear(); } diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 14f909566..b2cc5f9a0 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -652,8 +652,8 @@ fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { // Browse… is desktop-only. #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] let mut browse = false; - ui.radio_value(&mut d.new_id.create, false, tl!("Use a Digital ID from a file")); - ui.radio_value(&mut d.new_id.create, true, tl!("Create a new Digital ID (self-signed, saved to a password-protected file)")); + widgets::radio_value(ui, &mut d.new_id.create, false, tl!("Use a Digital ID from a file")); + widgets::radio_value(ui, &mut d.new_id.create, true, tl!("Create a new Digital ID (self-signed, saved to a password-protected file)")); ui.add_space(8.0); let n = &mut d.new_id; if n.create { @@ -831,24 +831,24 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { preview(ui, t, &entry.name, d, graphic.as_ref(), &mut app.sign_graphic_preview); ui.horizontal_wrapped(|ui| { let a = &mut d.appearance; - ui.checkbox(&mut a.name, tl!("Name")); - ui.checkbox(&mut a.date, tl!("Date")); - ui.checkbox(&mut a.reason, tl!("Reason")); - ui.checkbox(&mut a.location, tl!("Location")); - ui.checkbox(&mut a.distinguished_name, tl!("Distinguished name")); - ui.checkbox(&mut a.labels, tl!("Labels")); + ui.add(widgets::checkbox(&mut a.name, tl!("Name"))); + ui.add(widgets::checkbox(&mut a.date, tl!("Date"))); + ui.add(widgets::checkbox(&mut a.reason, tl!("Reason"))); + ui.add(widgets::checkbox(&mut a.location, tl!("Location"))); + ui.add(widgets::checkbox(&mut a.distinguished_name, tl!("Distinguished name"))); + ui.add(widgets::checkbox(&mut a.labels, tl!("Labels"))); }); // Acrobat: Configure Signature Appearance, Text, Draw or Image. ui.horizontal_wrapped(|ui| { ui.label(tl!("Graphic")); - ui.radio_value(&mut d.graphic, SignGraphic::Name, tl!("Signer's name")); + widgets::radio_value(ui, &mut d.graphic, SignGraphic::Name, tl!("Signer's name")); ui.add_enabled_ui(app.signature.is_some(), |ui| { - if ui.radio(d.graphic == SignGraphic::FillSign, tl!("Fill & Sign signature")).clicked() { + if ui.add(widgets::radio(d.graphic == SignGraphic::FillSign, tl!("Fill & Sign signature"))).clicked() { d.graphic = SignGraphic::FillSign; } }); ui.add_enabled_ui(d.image.is_some(), |ui| { - if ui.radio(matches!(d.graphic, SignGraphic::Image(_)), tl!("Image")).clicked() + if ui.add(widgets::radio(matches!(d.graphic, SignGraphic::Image(_)), tl!("Image"))).clicked() && let Some(image) = &d.image { d.graphic = SignGraphic::Image(image.clone()); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1fef87e40..bf440d3a4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -188,6 +188,76 @@ pub fn menu_item(ui: &mut egui::Ui, label: &str, shortcut: &str) -> Response { ui.add(egui::Button::new(label).shortcut_text(shortcut)) } +/// Corner radius of a checkbox's box. With the widgets' radius (`Tokens::radius`, 6) egui's 14 px +/// box is a circle, the shape of a radio button, and View ▸ Page display has both. +const CHECKBOX_RADIUS: u8 = 3; + +/// A checkbox whose box is an outlined rounded square, never mistaken for a radio button. Every +/// checkbox goes through here: clippy.toml disallows `ui.checkbox` and `egui::Checkbox`. +pub fn checkbox<'a>(checked: &'a mut bool, atoms: impl egui::IntoAtoms<'a>) -> impl egui::Widget + 'a { + #[expect(clippy::disallowed_methods, reason = "the one place that styles the box")] + let widget = egui::Checkbox::new(checked, atoms); + toggle(widget, Some(CHECKBOX_RADIUS)) +} + +/// [`checkbox`] without a label: the selection box on Organize Pages thumbnails. +pub fn checkbox_without_text(checked: &mut bool) -> impl egui::Widget + '_ { + #[expect(clippy::disallowed_methods, reason = "the one place that styles the box")] + let widget = egui::Checkbox::without_text(checked); + toggle(widget, Some(CHECKBOX_RADIUS)) +} + +/// A radio button with an outline and a fill, like [`checkbox`]. Every radio button goes through +/// here or [`radio_value`]: clippy.toml disallows `ui.radio`, `ui.radio_value` and +/// `egui::RadioButton`. +pub fn radio<'a>(selected: bool, atoms: impl egui::IntoAtoms<'a>) -> impl egui::Widget + 'a { + #[expect(clippy::disallowed_methods, reason = "the one place that styles the circle")] + let widget = egui::RadioButton::new(selected, atoms); + toggle(widget, None) +} + +/// [`radio`] selected while `*current == alternative`; a click selects it (`Ui::radio_value`). +pub fn radio_value<'a, V: PartialEq>(ui: &mut egui::Ui, current: &mut V, alternative: V, atoms: impl egui::IntoAtoms<'a>) -> Response { + let mut response = ui.add(radio(*current == alternative, atoms)); + if response.clicked() && *current != alternative { + *current = alternative; + response.mark_changed(); + } + response +} + +/// The box of a checkbox or the circle of a radio button. egui draws it with the widgets' visuals +/// and has no style of its own for it, so it is set in a scope around the widget, keeping what +/// the caller set (dialogs and the search panel already outline theirs): +/// - an outline, darker on hover; +/// - on the light theme, the plain fill (`Tokens::hover`), as dialogs have: the field colour is +/// the panels' white, so the box would vanish; +/// - for a checkbox, a corner radius small enough to keep the box square. +fn toggle<'a>(widget: impl egui::Widget + 'a, radius: Option) -> impl egui::Widget + 'a { + move |ui: &mut egui::Ui| { + ui.scope(|ui| { + let t = Tokens::get(ui.ctx()); + let w = &mut ui.visuals_mut().widgets; + if let Some(radius) = radius { + for v in [&mut w.noninteractive, &mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] { + v.corner_radius = CornerRadius::same(radius); + } + } + if !t.dark() && w.inactive.bg_fill == t.field { + w.inactive.bg_fill = t.hover; + } + if w.inactive.bg_stroke.is_empty() { + w.inactive.bg_stroke = Stroke::new(1.0, t.border); + } + if w.hovered.bg_stroke.is_empty() { + w.hovered.bg_stroke = Stroke::new(1.0, t.text_muted); + } + ui.add(widget) + }) + .inner + } +} + pub fn section_title(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(10.0); @@ -443,6 +513,70 @@ mod tests { crate::i18n::set_current(previous); } + /// View ▸ Page display mixes radio buttons and checkboxes. With the theme's 6 px widget radius + /// egui's 14 px checkbox box drew as a circle, and on the light theme an unticked box or an + /// unselected radio button was white on white with no outline, so it couldn't be seen. + #[test] + fn checkboxes_and_radio_buttons_are_outlined_filled_and_distinct() { + for kind in [theme::ThemeKind::Light, theme::ThemeKind::Dark] { + let ctx = egui::Context::default(); + theme::apply(&ctx, kind); + let t = Tokens::for_kind(kind); + let side = ctx.global_style().spacing.icon_width; + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.add(checkbox(&mut false, "Show cover page in two-page view")); + ui.add(radio(false, "Single page")); + }); + let boxes: Vec<_> = output + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Rect(r) if (r.rect.width() - side).abs() < 0.5 && (r.rect.height() - side).abs() < 0.5 => Some(r), + _ => None, + }) + .collect(); + let circles: Vec<_> = output + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Circle(c) if (c.radius * 2.0 - side).abs() < 1.0 => Some(c), + _ => None, + }) + .collect(); + assert_eq!((boxes.len(), circles.len()), (1, 1), "{kind:?}: one checkbox box and one radio circle"); + let r = boxes[0].corner_radius; + let radius = r.nw.max(r.ne).max(r.sw).max(r.se); + assert!(f32::from(radius) <= side / 4.0, "{kind:?}: a {side} px box with corner radius {radius} looks like a radio button"); + for (what, fill, edge) in [("checkbox", boxes[0].fill, boxes[0].stroke), ("radio button", circles[0].fill, circles[0].stroke)] { + assert!(fill != t.panel && fill != t.card, "{kind:?}: an unselected {what} is filled like the panel behind it"); + assert!( + edge.width >= 1.0 && edge.color != t.panel && edge.color != t.card, + "{kind:?}: an unselected {what} needs an outline, got {edge:?}" + ); + } + output.drop_without_applying_deltas(); + } + // An outline the caller set (dialogs) is kept. + let ctx = egui::Context::default(); + let own = Stroke::new(1.5, Color32::RED); + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.visuals_mut().widgets.inactive.bg_stroke = own; + ui.add(checkbox(&mut true, "Collate")); + ui.add(radio(false, "All")); + }); + let kept = output + .shapes + .iter() + .filter(|s| match &s.shape { + egui::Shape::Rect(r) => r.stroke == own, + egui::Shape::Circle(c) => c.stroke == own, + _ => false, + }) + .count(); + assert_eq!(kept, 2, "the checkbox and the radio button keep the caller's outline"); + output.drop_without_applying_deltas(); + } + fn toast_rect(app: &mut PdfCraftApp, ctx: &egui::Context, width: f32, now: &mut f64) -> Rect { // Let the Area settle after a notice/viewport change, without advancing to // expiry. No native rendering or renderer worker is needed for this layout. From f61a1e5465b01d5153156768a724a282cef8f632 Mon Sep 17 00:00:00 2001 From: jcl-bit <336218001+jcl-bit@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:35:02 -0700 Subject: [PATCH 12/16] M14: "Edit with PdfCraft" on the File Explorer menu of PDF files (#682) (#1180) * M14: "Edit with PdfCraft" on the File Explorer menu of PDF files (#682) The Windows MSI adds a classic shell verb under HKLM\Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit that runs "pdfcraft.exe" "%1" with the app's icon. It appears whichever app is the default for .pdf and never changes that default. On Windows 11 it is under "Show more options": a first-level entry needs an IExplorerCommand handler and package identity, which an MSI doesn't have. MultiSelectModel is Document: several selected PDFs (up to 15) start one launch each, and the existing single-instance hand-off opens them as tabs of one window. The verb has its own component, conditioned on a new secure public property INSTALLPDFCONTEXTMENU (default 1, a welcome-dialog checkbox, INSTALLPDFCONTEXTMENU=0 for unattended installs), following INSTALLDESKTOPSHORTCUT. Its menu value is the key path, so uninstall removes every value. The portable zip still registers nothing. test-msi.ps1 checks the compiled Registry, Component, Property and CheckBox rows, and that the .pdf default is never written. Docs: README, docs/releasing.md, parity notes, ROADMAP log. Co-Authored-By: Claude Opus 5.5 * Leave ROADMAP.md to main (its log moved on); the change is described in the PR --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- README.md | 10 ++++++--- docs/releasing.md | 16 ++++++++++---- packaging/windows/installer-ui.wxs | 4 +++- packaging/windows/pdfcraft.wxs | 25 +++++++++++++++++++--- packaging/windows/test-msi.ps1 | 34 +++++++++++++++++++++++++++++- parity/acrobat-features.toml | 4 ++-- 6 files changed, 79 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4f5792db9..e6227849c 100644 --- a/README.md +++ b/README.md @@ -448,14 +448,18 @@ The portable zip runs from any folder, a USB stick included. Its `portable.txt` logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to `%APPDATA%`; delete that file to use the normal per-user folders. -The MSI installs for all users and requires administrator privileges. For unattended deployment -without a desktop shortcut, run from an elevated terminal: +The MSI installs for all users and requires administrator privileges. It adds "Edit with PdfCraft" +to the right-click menu of PDF files in File Explorer (on Windows 11, under "Show more options"), +without changing which app opens PDFs by default. For unattended deployment without a desktop +shortcut, run from an elevated terminal: ```powershell msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0 ``` -Use the MSI for your architecture. Per-user installation overrides are not supported. +Add `INSTALLPDFCONTEXTMENU=0` to leave out the right-click menu entry. Pass both on upgrades too, +since each install applies its own choices. Use the MSI for your architecture. Per-user +installation overrides are not supported. ### macOS diff --git a/docs/releasing.md b/docs/releasing.md index 7f82e29d2..9576c4f13 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -119,10 +119,18 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. this. - `installer-ui.wxs` supplies native welcome, maintenance, progress, files-in-use and outcome dialogs. Full UI confirms success with Finish; failures and cancellations have distinct messages. - The welcome dialog has a "Create a desktop shortcut" checkbox, ticked by default. `/qn` and `/qb` - stay unattended and create the desktop shortcut unless `INSTALLDESKTOPSHORTCUT=0` is passed. -- `test-msi.ps1 ` checks the compiled shortcut and dialog tables. Packaging runs it in a - child process before signing, so the MSI isn't held open when signtool runs. The ARM64 install + The welcome dialog has a "Create a desktop shortcut" checkbox and an "Edit with PdfCraft" + right-click menu checkbox, both ticked by default. `/qn` and `/qb` stay unattended and create + both unless `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed. +- The PDF right-click entry (#682) is a classic verb under + `HKLM\Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit`, so it shows whichever app + is the default for `.pdf` and never changes that default. Several selected PDFs (up to 15, the + limit for a `Document` verb) start one `pdfcraft.exe ""` each, and the single-instance + hand-off opens them as tabs. On Windows 11 it is under "Show more options"; a first-level entry + needs an `IExplorerCommand` handler and package identity (a sparse MSIX package), which the MSI + doesn't have. +- `test-msi.ps1 ` checks the compiled shortcut, context-menu and dialog tables. Packaging + runs it in a child process before signing, so the MSI isn't held open when signtool runs. The ARM64 install smoke test checks that both all-users shortcuts point at the installed `pdfcraft.exe` and are removed on uninstall, and that `INSTALLDESKTOPSHORTCUT=0` skips the desktop one. - The portable zip holds both executables, the README, the licences, the OFL licence of each diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index da9658745..a437c2a8c 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -1,6 +1,6 @@ + Full UI gets welcome (with the desktop-shortcut and PDF context-menu checkboxes)/progress/outcome screens; /qn and /qb stay unattended. --> @@ -14,6 +14,8 @@ Text="Setup will install [ProductName] for all users and add it to the Start menu. Click Install to continue." /> + diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 11d2e39ab..e8bb53734 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -5,9 +5,10 @@ wix build pdfcraft.wxs installer-ui.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d ModelsDir=... -d IconPath=... -o out.msi Per-machine install into Program Files, a Start Menu shortcut, a desktop shortcut (on by default, - optional), an App Paths entry, and PdfCraft registered as a PDF handler (offered under "Open with" - and in Default apps, never forced as the default). Files opened with PdfCraft arrive on its - command line. + optional), an App Paths entry, PdfCraft registered as a PDF handler (offered under "Open with" + and in Default apps, never forced as the default), and "Edit with PdfCraft" on PDF files' + right-click menu (on by default, optional). Files opened with PdfCraft arrive on its command + line. --> + + @@ -136,6 +140,21 @@ + + + + + + + + diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 9c0d50b57..0b6fa7781 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -117,6 +117,38 @@ foreach ($ext in @('png', 'jpg', 'jpeg', 'tif', 'tiff', 'gif', 'bmp', 'jp2', 'j2 Assert-Equal $selection[0] 'Single' "$ext context menu selection" } +# PDF context menu (#682): "Edit with PdfCraft" opens the file in PdfCraft. Its own component, gated +# by INSTALLPDFCONTEXTMENU (default 1, secure, welcome checkbox), with its menu value as key path so +# uninstall removes it. Several selected PDFs each start pdfcraft.exe "%1" (Document), which the +# single-instance hand-off opens as tabs. The .pdf default is never set, so the user's default app +# stays. +$pdfKey = 'Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit' +$pdfMenu = Read-Row ('SELECT `Registry`, `Value`, `Component_`, `Root` FROM `Registry` WHERE `Key` = ''' + $pdfKey + ''' AND `Name` IS NULL') 4 +Assert-Equal $pdfMenu[1] 'Edit with PdfCraft' 'PDF context menu label' +Assert-Equal $pdfMenu[2] 'PdfcraftPdfContextMenu' 'PDF context menu component' +Assert-Equal $pdfMenu[3] '2' 'PDF context menu HKLM root' +foreach ($value in @(@('Icon', '"[#PdfcraftExe]",0'), @('MultiSelectModel', 'Document'))) { + $row = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $pdfKey + ''' AND `Name` = ''' + $value[0] + '''') 2 + Assert-Equal $row[0] $value[1] "PDF context menu $($value[0])" + Assert-Equal $row[1] 'PdfcraftPdfContextMenu' "PDF context menu $($value[0]) component" +} +$pdfCommand = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $pdfKey + '\command''') 2 +Assert-Equal $pdfCommand[0] '"[#PdfcraftExe]" "%1"' 'PDF context menu command' +Assert-Equal $pdfCommand[1] 'PdfcraftPdfContextMenu' 'PDF context menu command component' +$pdfComponent = Read-Row 'SELECT `Condition`, `KeyPath` FROM `Component` WHERE `Component` = ''PdfcraftPdfContextMenu''' 2 +Assert-Equal $pdfComponent[0] 'INSTALLPDFCONTEXTMENU = 1' 'PDF context menu condition' +Assert-Equal $pdfComponent[1] $pdfMenu[0] 'PDF context menu key path' +$pdfDefault = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''INSTALLPDFCONTEXTMENU''' 1 +Assert-Equal $pdfDefault[0] '1' 'PDF context menu default' +if (($secure[0] -split ';') -notcontains 'INSTALLPDFCONTEXTMENU') { throw "INSTALLPDFCONTEXTMENU is not secure: '$($secure[0])'" } +$pdfCheckbox = Read-Row 'SELECT `Type`, `Property`, `Text` FROM `Control` WHERE `Dialog_` = ''InstallWelcome'' AND `Control` = ''PdfContextMenu''' 3 +Assert-Equal $pdfCheckbox[0] 'CheckBox' 'Welcome PDF context-menu control' +Assert-Equal $pdfCheckbox[1] 'INSTALLPDFCONTEXTMENU' 'Welcome PDF context-menu property' +if ($pdfCheckbox[2] -notmatch 'Edit with PdfCraft') { throw "Welcome PDF context-menu label: '$($pdfCheckbox[2])'" } +$pdfChecked = Read-Row 'SELECT `Value` FROM `CheckBox` WHERE `Property` = ''INSTALLPDFCONTEXTMENU''' 1 +Assert-Equal $pdfChecked[0] '1' 'Welcome PDF context-menu value' +Assert-NoRow 'SELECT `Registry` FROM `Registry` WHERE `Key` = ''Software\Classes\.pdf'' AND `Name` IS NULL' '.pdf default app' + # Negative sequences are Windows Installer's success/user-exit/failure paths. Only full UI # shows these dialogs: an unattended /qn or /qb install must never wait for a Finish click. foreach ($exit in @(@('InstallComplete', '-1'), @('InstallCancelled', '-2'), @('InstallFailed', '-3'))) { @@ -147,4 +179,4 @@ $rm = Read-Row 'SELECT `Dialog` FROM `Dialog` WHERE `Dialog` = ''MsiRMFilesInUse Assert-Equal $rm[0] 'MsiRMFilesInUse' 'Files-in-use dialog' [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Database) [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Installer) -Write-Output 'ok MSI: per-machine scope guard, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut (default on, checkbox), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' +Write-Output 'ok MSI: per-machine scope guard, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut (default on, checkbox), optional PDF context menu (default on, checkbox), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0e68fc81d..0caf933dc 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -23,7 +23,7 @@ status = "shipped" commands = ["file.open"] tools = ["doc_open"] evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system"] -notes = "File ▸ Open, the command line (Linux and Windows file associations), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type)" +notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)" [[feature]] id = "core.repair-broken-files" @@ -10035,7 +10035,7 @@ status = "planned" commands = [] tools = [] evidence = [] -notes = "Windows MSI creates a Start menu shortcut and an optional (default on) desktop shortcut and confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported. Signed installers across platforms remain planned." +notes = "Windows MSI creates a Start menu shortcut, an optional (default on) desktop shortcut and an optional (default on, INSTALLPDFCONTEXTMENU) \"Edit with PdfCraft\" File Explorer menu entry for PDF files (#682), and confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported. Signed installers across platforms remain planned." [[feature]] id = "misc.web-deploy" From 287fce8f9cfc9c501164c6a3bcc2ad0b6c4ef690 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:38:48 -0700 Subject: [PATCH 13/16] M2: bound the total size of page-label prefixes (#1146) Both page-label readers decode each range's prefix into a string of its own: render's inspection for its range table, Organize for the ranges it returns. Ranges may share one prefix object, so a small file could ask for a copy per range: within the 200,000-entry tree limit, about 200 MiB of copies of one 1,024-byte prefix. Decoded prefixes now take at most 4 MiB in all (MAX_PREFIX_TOTAL_BYTES, beside MAX_LABEL_TOTAL_BYTES). Past that the tree is refused before any label is made, as the other tree limits refuse it. Tests: 5,000 ranges sharing a 1,000-byte prefix are refused in both readers (relabelling such a tree changes nothing), and exactly 4 MiB of prefixes is still read. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/cos/src/page_labels.rs | 5 +++++ crates/organize/src/labels.rs | 12 +++++++---- crates/organize/src/tests.rs | 29 +++++++++++++++++++++++++ crates/render/src/inspect.rs | 40 ++++++++++++++++++++++++++++++++++- 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 818c04976..352b0ba25 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-11 (M2, page-label prefixes):** The page-label readers bound the total size of decoded prefixes. Each range decodes its own prefix, and ranges may share one prefix object, so a small file could ask for a copy per range: within the 200,000-entry tree limit, about 200 MiB of copies of one 1,024-byte prefix. Inspection and Organize now refuse a tree whose decoded prefixes pass 4 MiB in total (`MAX_PREFIX_TOTAL_BYTES`), before any label is made, as the other tree limits do; relabelling such a tree changes nothing. Synthetic tests share one 1,000-byte prefix across 5,000 ranges and keep exactly 4 MiB of prefixes readable. Overall estimate unchanged. - **2026-10-11 (UI, checkboxes and radio buttons):** Checkboxes no longer look like radio buttons, and on the light theme neither is invisible. With the theme's 6 px widget radius egui drew a checkbox's 14 px box as a circle, so View ▸ Page display's Continuous scrolling and Show cover page read as two more layout choices; on the light theme an unticked box or an unselected radio button was white on white with no edge. Every checkbox (81) and radio button (79) now goes through `widgets::checkbox` / `widgets::radio` / `widgets::radio_value`: a 1 px outline (darker on hover), the plain fill on the light theme as dialogs already had, and for checkboxes a rounded-square box (3 px), keeping what the caller set (dialogs, the search panel). `clippy.toml` disallows `ui.checkbox`, `ui.radio`, `ui.radio_value`, `egui::Checkbox` and `egui::RadioButton` so new ones use them too. Estimates unchanged. - **2026-10-11 (M13, #971):** `text_find` now bounds each page's temporary match-range list to the remaining result limit plus one, which is enough to detect and return a continuation without allocating every match on a dense page. Added limited-search coverage; the existing end-to-end continuation test passes. The text cache byte budget and per-page extraction warnings remain open. - **2026-10-11 (M7, #594):** Ordinary paragraph body drags now use the existing source-preserving object translation path; edge drags still rewrap. An in-progress drag is discarded if the document generation changes, and added-content gestures keep their own edit handler. Added real-shell source regressions for overlapping and identical labels, history, save/reopen, partial overlap, kerning, rotation/crop/UserUnit and atomic refusal. Linux validation passed: focused UI/engine regressions, exact-base source-loss failures, native drag/undo/redo/save/reopen with independent Poppler renders, and all eight CI gates. Text replacement and resizing over unrelated overlapping content remain outside this repair. Estimates unchanged. diff --git a/crates/cos/src/page_labels.rs b/crates/cos/src/page_labels.rs index cf037bf1b..557929f8b 100644 --- a/crates/cos/src/page_labels.rs +++ b/crates/cos/src/page_labels.rs @@ -7,6 +7,11 @@ pub const MAX_LABEL_BYTES: usize = 1024; pub const MAX_PREFIX_BYTES: usize = 2 * MAX_LABEL_BYTES + 2; /// Sum of the UTF-8 bytes of custom labels for one document. pub const MAX_LABEL_TOTAL_BYTES: usize = 4 << 20; +/// Sum of the UTF-8 bytes of the decoded prefixes of one `/PageLabels` tree. Each range decodes +/// its own prefix, and ranges may share one prefix object, so without this a small file could ask +/// for a copy per range: [`MAX_LABEL_TREE_WORK`] ranges naming one 1,024-byte prefix would hold +/// about 200 MiB. Real documents stay far below it. +pub const MAX_PREFIX_TOTAL_BYTES: usize = 4 << 20; /// The most number-tree entries (nodes, kids and range pairs) read from one `/PageLabels` /// tree. Scaled for real documents: one range per page on a ten-thousand-page PDF happens /// (prefix labels; #307's file has 9,156 pages), so the cap is twenty times that while still diff --git a/crates/organize/src/labels.rs b/crates/organize/src/labels.rs index c351adc04..aa02be427 100644 --- a/crates/organize/src/labels.rs +++ b/crates/organize/src/labels.rs @@ -7,7 +7,7 @@ use std::collections::HashSet; -use pdfcraft_cos::page_labels::{MAX_LABEL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, alpha, roman}; +use pdfcraft_cos::page_labels::{MAX_LABEL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, alpha, roman}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; use crate::{OrganizeError, page_count, pages_root}; @@ -146,6 +146,7 @@ fn read_label_node( left: &mut usize, seen: &mut HashSet, entries: &mut Vec, + prefix_left: &mut usize, ) -> Result<(), OrganizeError> { label_work(left, 1)?; if depth > MAX_LABEL_TREE_DEPTH { @@ -179,6 +180,9 @@ fn read_label_node( if prefix.len() > MAX_LABEL_BYTES { return Err(invalid_label("a decoded prefix exceeds 1024 UTF-8 bytes")); } + // A prefix object shared by many ranges is copied into each: count every copy. + *prefix_left = + prefix_left.checked_sub(prefix.len()).ok_or_else(|| invalid_label("decoded prefixes exceed 4 MiB in total"))?; let style = spec.name(b"S").map_or(LabelStyle::None, LabelStyle::from_name); let first = spec.int(b"St").unwrap_or(1).max(1); let first = if style == LabelStyle::None { @@ -203,7 +207,7 @@ fn read_label_node( } // Recursion is bounded before descending, and arrays are borrowed throughout. for kid in kids { - read_label_node(doc, kid, depth + 1, left, seen, entries)?; + read_label_node(doc, kid, depth + 1, left, seen, entries, prefix_left)?; } Ok(()) })?; @@ -217,8 +221,8 @@ fn checked_ranges(doc: &Document) -> Result, OrganizeError> { let catalog = doc.get(root); let Some(tree) = catalog.as_dict().and_then(|c| c.get(b"PageLabels")) else { return Ok(Vec::new()) }; let mut entries = Vec::new(); - let mut left = MAX_LABEL_TREE_WORK; - read_label_node(doc, tree, 0, &mut left, &mut HashSet::new(), &mut entries)?; + let (mut left, mut prefix_left) = (MAX_LABEL_TREE_WORK, MAX_PREFIX_TOTAL_BYTES); + read_label_node(doc, tree, 0, &mut left, &mut HashSet::new(), &mut entries, &mut prefix_left)?; // A start page given twice keeps its later range, as readers that overwrite do (the sort is // stable, so the later one is last among equals). entries.sort_by_key(|r| r.start); diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 50f35ebe7..1014ddba1 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1477,6 +1477,35 @@ fn number_pages_past_four_mib_in_total_is_accepted() { assert_eq!(crate::page_labels(&d).unwrap()[4_999], format!("{}5000", "x".repeat(1000))); } +/// Each range keeps its own decoded prefix, so a small file sharing one prefix object asks for a +/// copy per range (about 200 MiB at the tree-work limit). Past 4 MiB of decoded prefixes in all +/// the tree is refused, and so is relabelling it, which changes nothing. +#[test] +fn page_label_prefixes_are_bounded_in_total() { + // `ranges` ranges naming one shared prefix object of `prefix` bytes, without a style. + let shared = |ranges: i64, prefix: usize| { + let mut d = doc_a(); + let prefix = d.add(Object::String(PdfString::text(&"x".repeat(prefix)))); + let mut spec = Dict::new(); + spec.set(b"P".to_vec(), Object::Ref(prefix)); + let spec = d.add(Object::Dict(spec)); + let mut tree = Dict::new(); + tree.set(b"Nums".to_vec(), Object::Array((0..ranges).flat_map(|i| [Object::Int(i), Object::Ref(spec)]).collect())); + let tree = d.add(Object::Dict(tree)); + d.update_dict(d.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + d + }; + let mut d = shared(5_000, 1_000); + let e = crate::page_labels(&d).unwrap_err().to_string(); + assert!(e.contains("decoded prefixes exceed 4 MiB in total"), "{e}"); + let before = d.modified_objects(); + assert!(crate::number_pages(&mut d, 0, 1, crate::LabelStyle::Decimal, "", 1).is_err()); + assert_eq!(d.modified_objects(), before, "a refused relabelling changes nothing"); + // Exactly 4 MiB of prefixes is still read. + let max = pdfcraft_cos::page_labels::MAX_PREFIX_TOTAL_BYTES / 1024; + assert!(crate::page_labels(&shared(max as i64, 1024)).unwrap().iter().all(|l| l.len() == 1024)); +} + #[test] fn page_boxes_default_inherit_and_set() { let mut doc = open(fixture()); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 1343c2b51..ebe687620 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -14,7 +14,9 @@ use hayro::hayro_syntax::DecryptionError; use hayro::hayro_syntax::LoadPdfError; use hayro::hayro_syntax::Pdf; use lopdf::{Dictionary, Document, LoadOptions, Object, ObjectId}; -use pdfcraft_cos::page_labels::{MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, alpha, roman}; +use pdfcraft_cos::page_labels::{ + MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, alpha, roman, +}; use crate::OpenError; use crate::structure::{LazyStructure, Structure}; @@ -814,6 +816,7 @@ impl<'a> Inspector<'a> { } // Decode each bounded prefix once, rather than once for every page in its range. let mut specs = BTreeMap::new(); + let mut prefix_total = 0usize; for (start, d) in ranges { let first = d.get(b"St").ok().and_then(|o| self.resolve(o).as_i64().ok()).unwrap_or(1).max(1) as u64; let prefix = match d.get(b"P").ok().map(|o| self.resolve(o)) { @@ -828,6 +831,11 @@ impl<'a> Inspector<'a> { if prefix.len() > MAX_LABEL_BYTES { return Err("a decoded prefix exceeds 1024 UTF-8 bytes"); } + // A prefix object shared by many ranges is decoded once per range: count every copy. + prefix_total = prefix_total.saturating_add(prefix.len()); + if prefix_total > MAX_PREFIX_TOTAL_BYTES { + return Err("decoded prefixes exceed 4 MiB in total"); + } let style = d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()); specs.insert(start, (first, prefix, style)); } @@ -1770,6 +1778,36 @@ mod tests { assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); } + #[test] + fn page_label_limits_bound_the_total_of_decoded_prefixes() { + // `ranges` ranges naming one shared prefix object of `prefix` bytes, without a style. + let shared = |ranges: i64, prefix: usize| { + let mut doc = Document::with_version("1.7"); + doc.objects.insert((7, 0), Object::String(vec![b'x'; prefix], lopdf::StringFormat::Literal)); + let mut spec = Dictionary::new(); + spec.set("P", Object::Reference((7, 0))); + doc.objects.insert((8, 0), Object::Dictionary(spec)); + let mut tree = Dictionary::new(); + tree.set("Nums", (0..ranges).flat_map(|i| [Object::Integer(i), Object::Reference((8, 0))]).collect::>()); + let mut catalog = Dictionary::new(); + catalog.set("PageLabels", Object::Dictionary(tree)); + (doc, catalog) + }; + let mut pages: Vec<_> = + (1..=3).map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }).collect(); + // Each range decodes its prefix, so a small file sharing one prefix object asks for a + // copy per range: 5,000 ranges and a 1,000-byte prefix make 5 MB, past the 4 MiB total. + // Three pages make three labels, far below the label total: the prefixes alone refuse it. + let (doc, catalog) = shared(5000, 1000); + let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err(); + assert!(err.contains("prefixes exceed 4 MiB"), "{err}"); + assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string())); + // Exactly 4 MiB of prefixes is still read. + let (doc, catalog) = shared((MAX_PREFIX_TOTAL_BYTES / MAX_LABEL_BYTES) as i64, MAX_LABEL_BYTES); + Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap(); + assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); + } + #[test] fn page_label_limits_handle_prefix_only_and_reference_cycles() { let info = label_info(vec![Object::Integer(0), label_spec("", b"prefix".to_vec(), i64::MAX)], Vec::new()); From fb97ce958dfe92b6762ce40615c7836551710139 Mon Sep 17 00:00:00 2001 From: Dimitar Date: Mon, 12 Oct 2026 01:41:02 +0300 Subject: [PATCH 14/16] M10: Windows two-sided binds on the chosen edge; note drivers that ignore Two-sided (#1154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The print script mapped "Flip on long edge" to System.Drawing's Duplex.Horizontal and "short edge" to Vertical. Those are DEVMODE's DMDUP_HORIZONTAL (short-edge binding) and DMDUP_VERTICAL (long-edge binding), so every Windows two-sided job bound on the other edge: a PCL driver received @PJL SET BINDING = TOP for long edge. They are swapped back. Win32_Printer's Capabilities say whether a driver prints two-sided (3, Duplex Printing). The printer list already comes from Win32_Printer, so it now carries that as Printer::two_sided (None on CUPS), the printers tool reports it, and the Print dialog notes when the selected printer's driver doesn't take two-sided settings and points to Properties…. Such drivers keep their own setting even with Two-sided Off. No vendor-specific names are used. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 38 ++++++++++++++--------------- crates/automation/src/printing.rs | 11 ++++++++- crates/print/src/spool.rs | 33 +++++++++++++++++-------- crates/print/src/tests.rs | 36 +++++++++++++++++++++------ crates/print/src/windows_print.ps1 | 6 +++-- 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/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/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/print_ui.rs | 7 ++++++ crates/ui-egui/tests/print.rs | 31 ++++++++++++++++++++++- 26 files changed, 140 insertions(+), 41 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 002b91c90..61729e35b 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ 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 = "7ed6a7c482dab19fe1b54a7704902610e79e9bc60f19115ce8af7b1ebca504ff" +sha256 = "1049c27ed90afac974f371c7e7e3bff94a2baebde2d820adc4c4c84e822e4cdd" # 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`). @@ -30,7 +30,7 @@ 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 = "e5141febe07d89c4c4c7b2d2f87abf71ad1ab8dc4a7880d03805db6f0022118b" +sha256 = "c30f72a1e902ddee4cb902560aaa91dfd3567490fd64db972f74a64fdb9af62c" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ 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 = "6dff4fded1106659f7c0b1c7e2c671c02e59313ab58875fabf591a9356098767" +sha256 = "ad2c35209b7dbcd366038e6a179d695937ef1c3e333f64eb803de738766fb3e9" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ 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 = "1978cfd55b08fe9905e00ddd16a10d3d0f5371143e2f00a58a77affcc1d874c3" +sha256 = "5d5456a0e51a3287ada0faec559dc137197159e64b4df8e7ee22ac8c563a9de9" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ 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 = "928435778bd8c703083087d401851ab01dc038db3ff568e572b6cf646f805b03" +sha256 = "f0b1e7ac0bf248cf09bf2d038e9224c24d2edb4c3b3ba92b3806f9384161e77b" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ 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 = "4f95f2dc524177a5a6261f2f6df6def2cd061596bddac20b264cf6051978b3e3" +sha256 = "468443e23821e248c934e222b245e87f926c38786575b6290d33f959d39a20bf" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ 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 = "7c0df34f12030b1dc266413839dbb507bc890cc8847f6b2eac324ed54ff3fc19" +sha256 = "575b7487137ef8c4b4ae33fc7bdbd50370fa28078e41727216046fdc5b89b81a" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ 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 = "77f4ec8eb9cba7e2f2aad0883a91a62ca871140135c586f32c725f66774a0632" +sha256 = "30f526fc7a1c14737bbaea272f45f123ffe61107188fe74375bd51b4808c9575" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -118,7 +118,7 @@ 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 = "3d1af7e703706bf4c814062871b47308b70f358356c3b17782dde3eaaa66abb7" +sha256 = "8404b7ae758b3cddb28efd8f27405d260cf4193e3fecf68ea9422aebd4d063b4" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ 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 = "d03048fe760c7daf08c0d8ed8470633c0eaca8f68c41d1c8ce889813c7f6fab1" +sha256 = "0ae768bcb0d7d3c24f5636d85df1532e626452ff97397312270379501a025c52" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ 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 = "69e07b82ae409de7544f538fedc8e56cdb9f4092be7f45cc7366bddbc03ce93f" +sha256 = "7b0798851885f59105046e861f682f529f5666b0ef8edc3fb3ba6255459d80ff" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ 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 = "0a71d58a88ea532358d3b133eba3aae217ad4bcbb763b6235b137615ce226d2b" +sha256 = "b1c44f5f1b6deadcdbe216019750f24beeac5533b6f519be4e361c97b57d5ac1" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ 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 = "1e5172befbe995c800cd69e9d6abc02c92544b62d8e6dac0f4a0c26cd0b1efbb" +sha256 = "9dc87fafc98f9d30c4bafa8014f19b32b0e83a0608b2202e647988abb4e6303d" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ 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 = "dd76d58abda6153e5c0b9fcc761d652cf2ed96522fab18c9ce87e9cec594043a" +sha256 = "af1cf608a1561057c530d5716ea8aac27dacf9fe4ab49741743b980f587a1409" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ 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 = "6c1ca7f6d53454c69b3e362a2a09c5c28d04b615c53936c639e3b43c880552d3" +sha256 = "f0e5375a5ee6b1151e312ba333f1a9a9ce0c55b27928da9bd6cff1ba95b8a5a1" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ 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 = "9151c1049289e318409965c5ad061448db9f35a7e8f14a754ba83616e3d87930" +sha256 = "9663c11a1f02c55bafb37ed2b12fc4d8542a2f2ed1c6dfdefd0b1f0629208cb8" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ 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 = "096be489498c16dbf85fcd6d2d0d96590736a8b0bbe3b3a5b557e2627c89f9c1" +sha256 = "3bfd85b5f6d3ab75573fa7511487561d8ee8b37a69dbf361c5b142cd50fb38e6" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ 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 = "e72215bc4bf21f598bf82a6ac55551bdef47d62cceedea239d89c0febd9cb1b7" +sha256 = "e9f46d2738530d4114b9e0a3e3c51bbd2df9bc51cb84506c10f499baa566650d" [[asset]] path = "assets/icons/accessibility.svg" @@ -3339,4 +3339,4 @@ 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 = "7cc8bf51e042d07a397dbebf0747b496879e721a6644f1af197cfb3cbcbe7904" +sha256 = "855e743c3b21f4d8e158d30c5757ead4f2b6b8bf830c87115ae99e6d16da76a3" diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 533f94835..15b56eae8 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -12,7 +12,16 @@ fn bad(m: impl Into) -> ToolError { impl Automation { pub(crate) fn printers(&self) -> Result { - let list: Vec = spool::printers().into_iter().map(|p| json!({ "name": p.name, "default": p.default })).collect(); + let list: Vec = spool::printers() + .into_iter() + .map(|p| { + let mut v = json!({ "name": p.name, "default": p.default }); + if let Some(two_sided) = p.two_sided { + v["two_sided"] = json!(two_sided); + } + v + }) + .collect(); Ok(json!({ "count": list.len(), "printers": list })) } diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 92a54fa81..0d949aae6 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -12,6 +12,10 @@ use crate::PrintError; pub struct Printer { pub name: String, pub default: bool, + /// Whether the driver says it can print two-sided, where the spooler tells (Windows: the + /// printer's `Duplex Printing` capability). A driver without it ignores Two-sided and uses + /// its own setting. `None` when unknown (CUPS maps `sides` itself). + pub two_sided: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] @@ -53,7 +57,7 @@ pub fn parse_lpstat(out: &str) -> Vec { out.lines() .filter_map(|l| l.strip_prefix("printer ")) .filter_map(|l| l.split_whitespace().next()) - .map(|n| Printer { name: n.to_string(), default: default == Some(n) }) + .map(|n| Printer { name: n.to_string(), default: default == Some(n), two_sided: None }) .collect() } @@ -340,7 +344,7 @@ pub fn printers_parsed(queues: &str, available: Option<&str>) -> Vec { if let Some(names) = available { for name in parse_lpstat_e(names) { if !printers.iter().any(|p| p.name == name) { - printers.push(Printer { default: default == Some(name.as_str()), name }); + printers.push(Printer { default: default == Some(name.as_str()), name, two_sided: None }); } } } @@ -367,23 +371,32 @@ pub(crate) fn powershell_command(script: &str) -> std::process::Command { c } -/// The Windows printer list: one line per printer, `*` on the system default. UTF-8, through -/// the output encoding the script sets first. Only the two properties the list needs are +/// The Windows printer list: one line per printer, `*` on the system default, then a tab and +/// `2` when the driver reports the Duplex Printing capability (3), `1` when it doesn't. UTF-8, +/// through the output encoding the script sets first. Only the properties the list needs are /// pulled, so a machine with many printers doesn't wait for the rest of each record. #[cfg(windows)] -const POWERSHELL_PRINTERS: &str = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Get-CimInstance Win32_Printer -Property Name, Default | ForEach-Object { if ($_.Default) { '*' + $_.Name } else { $_.Name } }"; +const POWERSHELL_PRINTERS: &str = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Get-CimInstance Win32_Printer -Property Name, Default, Capabilities | ForEach-Object { $sides = if (@($_.Capabilities) -contains 3) { '2' } else { '1' }; if ($_.Default) { '*' + $_.Name + [char]9 + $sides } else { $_.Name + [char]9 + $sides } }"; /// The Print dialog's printers from the Windows printer list: one per line, `*` before the -/// system default (a Windows queue name may not contain `*`). The first `*` wins when a broken -/// spooler marks more than one, so the dialog still has a single default to preselect. +/// system default (a Windows queue name may not contain `*`), and after a tab `2` or `1` for +/// whether the driver can print two-sided. The first `*` wins when a broken spooler marks more +/// than one, so the dialog still has a single default to preselect. pub fn parse_windows_printers(out: &str) -> Vec { let mut printers: Vec = out .lines() .map(|l| l.strip_suffix('\r').unwrap_or(l)) .filter(|l| !l.is_empty()) - .map(|l| match l.strip_prefix('*') { - Some(name) => Printer { name: name.to_string(), default: true }, - None => Printer { name: l.to_string(), default: false }, + .map(|l| { + let (l, two_sided) = match l.rsplit_once('\t') { + Some((name, "2")) => (name, Some(true)), + Some((name, "1")) => (name, Some(false)), + _ => (l, None), + }; + match l.strip_prefix('*') { + Some(name) => Printer { name: name.to_string(), default: true, two_sided }, + None => Printer { name: l.to_string(), default: false, two_sided }, + } }) .collect(); let mut seen_default = false; diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 935592297..bf186fb7d 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -234,7 +234,10 @@ fn spooler_arguments_and_printer_list() { let out = "printer Office_Laser is idle. enabled since Thu Oct 1 09:00:00 2026\nprinter Label_Writer disabled since …\nsystem default destination: Office_Laser\n"; assert_eq!( parse_lpstat(out), - [spool::Printer { name: "Office_Laser".into(), default: true }, spool::Printer { name: "Label_Writer".into(), default: false }] + [ + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, + spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None } + ] ); assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty()); let job = Job { @@ -274,16 +277,19 @@ fn driverless_printers_join_the_list_without_a_queue() { let queues = "printer Office_Laser is idle. enabled since Thu Oct 1 09:00:00 2026\nsystem default destination: Office_Laser\n"; assert_eq!( spool::printers_parsed(queues, Some("Basement_Color\nOffice_Laser\n")), - [spool::Printer { name: "Office_Laser".into(), default: true }, spool::Printer { name: "Basement_Color".into(), default: false },], + [ + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, + spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None }, + ], "queues first, then the driverless destination; the queue listed twice is not doubled" ); // An old spooler without `-e` (CUPS < 1.7) keeps its queues; only the driverless names are lost. - assert_eq!(spool::printers_parsed(queues, None), [spool::Printer { name: "Office_Laser".into(), default: true }]); + assert_eq!(spool::printers_parsed(queues, None), [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }]); // The default can itself be driverless (`lpoptions -d` names a destination no queue exists // for): it keeps its marker, so the dialog preselects the user's default, not the first queue. assert_eq!( spool::printers_parsed("system default destination: Basement_Color\n", Some("Basement_Color\n")), - [spool::Printer { name: "Basement_Color".into(), default: true }], + [spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None }], "a default destination without a queue is still the default" ); assert!(spool::parse_lpstat_e("\n \n").is_empty(), "no destinations is empty, not [\"\"]"); @@ -443,12 +449,26 @@ fn windows_printers_carry_one_default_and_keep_their_names() { // Windows marks the system default with a `*` no queue name may contain; names hold spaces, // parentheses and non-ASCII letters, and the lines end with CRLF. let printers = parse_windows_printers("*Microsoft Print to PDF\r\nHP LaserJet 400 (Copy 2)\r\nКопир Canon iR\r\n\r\n*XPS via USB\r\n"); - assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true }); - assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false }); - assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false }); + assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None }); + assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None }); + assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None }); // A spooler marking two defaults: the first still wins, so the dialog has one to preselect. - assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false }); + assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None }); assert_eq!(parse_windows_printers("\r\n"), Vec::new()); + // After a tab, whether the driver reports two-sided printing; a tab inside a name stays. + let sided = parse_windows_printers("*Office PCL\t2\r\nOffice PS\t1\r\nTab\tName\t2\r\n"); + assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true) }); + assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false) }); + assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true) }); +} + +#[test] +fn the_windows_print_script_binds_long_edge_on_the_long_edge() { + // System.Drawing's Duplex values are DEVMODE's: Vertical binds a portrait page on its long + // edge, Horizontal on its short edge (checked against a PCL driver's @PJL SET BINDING). + let script = include_str!("windows_print.ps1").replace("\r\n", "\n"); + assert!(script.contains("'long-edge' { [System.Drawing.Printing.Duplex]::Vertical }")); + assert!(script.contains("'short-edge' { [System.Drawing.Printing.Duplex]::Horizontal }")); } #[test] diff --git a/crates/print/src/windows_print.ps1 b/crates/print/src/windows_print.ps1 index 095b929ff..2292c297c 100644 --- a/crates/print/src/windows_print.ps1 +++ b/crates/print/src/windows_print.ps1 @@ -97,9 +97,11 @@ if ($env:PDFCRAFT_PRINT_PRINTER) { } $settings.Copies = [Math]::Min(32767, [Math]::Max(1, [int]$env:PDFCRAFT_PRINT_COPIES)) $settings.Collate = ($env:PDFCRAFT_PRINT_COLLATE -eq '1') +# DEVMODE's names are about the binding edge of a portrait page: Vertical (DMDUP_VERTICAL) +# binds on the long edge, Horizontal (DMDUP_HORIZONTAL) on the short edge. $settings.Duplex = switch ($env:PDFCRAFT_PRINT_DUPLEX) { - 'long-edge' { [System.Drawing.Printing.Duplex]::Horizontal } - 'short-edge' { [System.Drawing.Printing.Duplex]::Vertical } + 'long-edge' { [System.Drawing.Printing.Duplex]::Vertical } + 'short-edge' { [System.Drawing.Printing.Duplex]::Horizontal } default { [System.Drawing.Printing.Duplex]::Simplex } } if ($env:PDFCRAFT_PRINT_GRAYSCALE -eq '1') { $printer.DefaultPageSettings.Color = $false } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index c2b21d8d3..252a2aa79 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2270,3 +2270,4 @@ scroll speed Fast سريعة Original الأصل Greyscale تدرج رمادي Black and white أبيض وأسود + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… لا يقبل برنامج تشغيل هذه الطابعة إعدادات الطباعة على الوجهين من البرامج الأخرى، لذا يُطبَّق إعداده الخاص. غيّره من خصائص… diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 91c77d442..5290ffee7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2212,3 +2212,4 @@ scroll speed Fast Висока Original Оригинал Greyscale Нива на сивото Black and white Черно-бяло + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйверът на този принтер не приема настройки за двустранен печат от други програми и прилага собствената си настройка. Променете я от Свойства… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 604f4f45d..bebd1cac4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2324,5 +2324,6 @@ scroll speed Fast Rychlá Original Originál Greyscale Stupně šedi Black and white Černobílé + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Ovladač této tiskárny nepřijímá nastavení oboustranného tisku z jiných programů, proto platí jeho vlastní nastavení. Změňte ho ve Vlastnosti… 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. Písmo tohoto pole neumí zobrazit „{c}“ ({code}), proto PdfCraft místo něj ukazuje ?. Hodnota se uloží a jiné prohlížeče PDF pole podle ní překreslí. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Písma, kterými PdfCraft píše, neumí zobrazit „{c}“ ({code}), proto se ve všech prohlížečích PDF zobrazí jako ?. Text se uloží. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4f6e87570..278fb7cce 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2250,3 +2250,4 @@ scroll speed Fast Schnell Original Original Greyscale Graustufen Black and white Schwarzweiß + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Der Treiber dieses Druckers übernimmt keine Einstellungen für beidseitigen Druck von anderen Programmen, daher gilt seine eigene Einstellung. Ändern unter Eigenschaften… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 336e0c877..3513d352c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2266,5 +2266,6 @@ scroll speed Fast Γρήγορη Original Πρωτότυπο Greyscale Κλίμακα του γκρι Black and white Ασπρόμαυρο + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Το πρόγραμμα οδήγησης αυτού του εκτυπωτή δεν δέχεται ρυθμίσεις εκτύπωσης διπλής όψης από άλλα προγράμματα, οπότε ισχύει η δική του ρύθμιση. Αλλάξτε τη στις Ιδιότητες… 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 επανασχεδιάζουν το πεδίο από αυτήν. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Οι γραμματοσειρές με τις οποίες γράφει το PdfCraft δεν μπορούν να σχεδιάσουν το «{c}» ({code}), γι' αυτό εμφανίζεται ως ? σε κάθε πρόγραμμα προβολής PDF. Το κείμενο αποθηκεύεται. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d046a2aae..9c9b36c56 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2300,3 +2300,4 @@ scroll speed Fast Rápida Original Original Greyscale Escala de grises Black and white Blanco y negro + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… El controlador de esta impresora no acepta los ajustes de doble cara de otros programas, así que se aplica su propio ajuste. Cámbielo en Propiedades… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 66521af6d..38d692675 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2249,3 +2249,4 @@ scroll speed Fast Rapide Original Original Greyscale Niveaux de gris Black and white Noir et blanc + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Le pilote de cette imprimante n'accepte pas les réglages recto verso des autres programmes ; son propre réglage s'applique. Modifiez-le dans Propriétés… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9b187e626..101428e4a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2248,3 +2248,4 @@ scroll speed Fast Gyors Original Eredeti Greyscale Szürkeárnyalatos Black and white Fekete-fehér + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… A nyomtató illesztőprogramja nem fogadja el más programoktól a kétoldalas nyomtatás beállítását, ezért a saját beállítása érvényes. Módosítás: Tulajdonságok… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 4c2cbb78e..ba10d5ba9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2258,3 +2258,4 @@ scroll speed Fast Veloce Original Originale Greyscale Scala di grigi Black and white Bianco e nero + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Il driver di questa stampante non accetta le impostazioni fronte-retro da altri programmi, quindi vale la sua impostazione. Modificala in Proprietà… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b191a7046..717e3687c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2249,3 +2249,4 @@ scroll speed Fast 速い Original オリジナル Greyscale グレースケール Black and white 白黒 + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… このプリンターのドライバーは他のプログラムからの両面印刷の設定を受け付けないため、ドライバー自身の設定が使われます。変更先: プロパティ… diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 669078db9..0c76f370b 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2248,5 +2248,6 @@ scroll speed Fast Жылдам Original Түпнұсқа Greyscale Сұр реңктер Black and white Ақ-қара + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Бұл принтердің драйвері басқа бағдарламалардан екі жақты басып шығару баптауларын қабылдамайды, сондықтан өз баптауы қолданылады. Оны мына жерден өзгертіңіз: Сипаттар… 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 қарау құралдары өрісті соған қарап қайта салады. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft жазатын қаріптер «{c}» ({code}) таңбасын сала алмайды, сондықтан ол барлық PDF қарау құралдарында ? болып көрінеді. Мәтін сақталады. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 56e2f243d..7b22aadc0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2232,3 +2232,4 @@ scroll speed Fast Szybka Original Oryginał Greyscale Skala szarości Black and white Czarno-biały + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Sterownik tej drukarki nie przyjmuje ustawień druku dwustronnego z innych programów, więc obowiązuje jego własne ustawienie. Zmień je w oknie Właściwości… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 878439980..0b3e174f3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2263,3 +2263,4 @@ scroll speed Fast Rápida Original Original Greyscale Escala de cinza Black and white Preto e branco + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… O driver desta impressora não aceita configurações de frente e verso de outros programas, então vale a configuração dele. Altere em Propriedades… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1784d8ab9..443c73db0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2162,3 +2162,4 @@ scroll speed Fast Высокая Original Оригинал Greyscale Оттенки серого Black and white Чёрно-белый + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйвер этого принтера не принимает настройки двусторонней печати от других программ, поэтому действует его собственная настройка. Измените её в окне Свойства… diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index da8ae8b0c..a5a465a0f 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2255,5 +2255,6 @@ scroll speed Fast Hitro Original Izvirnik Greyscale Sivine Black and white Črno-belo + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Gonilnik tega tiskalnika ne sprejema nastavitev obojestranskega tiskanja iz drugih programov, zato velja njegova lastna nastavitev. Spremenite jo v Lastnosti… 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. Pisava tega polja ne more prikazati »{c}« ({code}), zato PdfCraft namesto njega prikaže ?. Vrednost se shrani, drugi pregledovalniki PDF pa polje znova narišejo iz nje. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Pisave, s katerimi piše PdfCraft, ne morejo prikazati »{c}« ({code}), zato se v vseh pregledovalnikih PDF prikaže kot ?. Besedilo se shrani. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 510f49937..6db2ea5bb 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2246,5 +2246,6 @@ scroll speed Fast Hızlı Original Orijinal Greyscale Gri tonlamalı Black and white Siyah beyaz + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Bu yazıcının sürücüsü diğer programlardan gelen çift taraflı yazdırma ayarlarını kabul etmez, bu yüzden kendi ayarı geçerlidir. Değiştirmek için: Özellikler… 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. Bu alanın yazı tipi “{c}” ({code}) karakterini çizemiyor, bu yüzden PdfCraft yerine ? gösteriyor. Değer kaydedilir ve diğer PDF görüntüleyiciler alanı ondan yeniden çizer. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft'ın yazdığı yazı tipleri “{c}” ({code}) karakterini çizemiyor, bu yüzden her PDF görüntüleyicide ? olarak görünür. Metin kaydedilir. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 69ac8f85c..592d3eba3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2271,3 +2271,4 @@ scroll speed Fast Швидка Original Оригінал Greyscale Відтінки сірого Black and white Чорно-білий + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйвер цього принтера не приймає налаштування двобічного друку від інших програм, тому діє його власне налаштування. Змініть його у вікні Властивості… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4c6ddf8ca..0feed72ea 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2213,3 +2213,4 @@ scroll speed Fast 快 Original 原始 Greyscale 灰度 Black and white 黑白 + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… 此打印机的驱动程序不接受其他程序的双面打印设置,因此使用其自身设置。更改位置:属性… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5722dc797..4c2b5aa60 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2126,3 +2126,4 @@ scroll speed Fast 快 Original 原始 Greyscale 灰階 Black and white 黑白 + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… 此印表機的驅動程式不接受其他程式的雙面列印設定,因此使用其自身設定。變更位置:屬性… diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 2f1924d1a..16f90f72f 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -617,6 +617,13 @@ pub(crate) fn body( }); ui.end_row(); }); + // Windows says which drivers can't print two-sided as they're asked; they keep their + // own setting, even when Two-sided is Off. + if let Some(p) = d.printers.iter().find(|p| d.printer.as_ref() == Some(&p.name)) + && p.two_sided == Some(false) + { + ui.label(egui::RichText::new(tl!("This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties…")).small().color(t.text_muted)); + } if let Some(e) = &d.driver_error { ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Couldn't open the printer's preferences: {e}"), &[("e", e)])).small().color(t.text_muted)); } diff --git a/crates/ui-egui/tests/print.rs b/crates/ui-egui/tests/print.rs index 4db97e8b0..2486c474a 100644 --- a/crates/ui-egui/tests/print.rs +++ b/crates/ui-egui/tests/print.rs @@ -205,7 +205,10 @@ fn printers_listed_in_the_background_preselect_the_default_unless_one_was_picked let wait = gate.clone(); h.state_mut().printer_source = Some(Arc::new(move || { let _open = wait.lock(); - vec![Printer { name: "Office".into(), default: false }, Printer { name: "Front Desk".into(), default: true }] + vec![ + Printer { name: "Office".into(), default: false, two_sided: None }, + Printer { name: "Front Desk".into(), default: true, two_sided: None }, + ] })); assert!(h.state_mut().execute("print.dialog")); h.run_steps(2); @@ -282,3 +285,29 @@ fn custom_page_cannot_silently_match_a_standard_paper() { assert_eq!(h.state().print_draft.paper, before.paper); assert_eq!(h.state().print_draft.size, before.size); } + +/// A driver Windows reports without two-sided printing keeps its own setting whatever Two-sided +/// says (some print both sides with it Off), so the dialog says where to change it. +#[test] +fn a_printer_without_two_sided_printing_points_to_its_properties() { + use pdfcraft_engine::print::spool::Printer; + let mut h = harness(); + h.state_mut().printer_source = Some(std::sync::Arc::new(|| { + vec![ + Printer { name: "Office PCL".into(), default: true, two_sided: Some(true) }, + Printer { name: "Office PS".into(), default: false, two_sided: Some(false) }, + ] + })); + assert!(h.state_mut().execute("print.dialog")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().print_draft.printers.is_empty() && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); + let note = "doesn't take two-sided settings"; + assert!(h.query_by_label_contains(note).is_none(), "a driver that prints two-sided as asked"); + h.state_mut().print_draft.printer = Some("Office PS".into()); + h.run_steps(2); + h.get_by_label_contains(note); +} From 634f3d1144dc34b94ca1456790f7cab62019a4a0 Mon Sep 17 00:00:00 2001 From: Raj Patel Date: Sun, 11 Oct 2026 16:45:36 -0600 Subject: [PATCH 15/16] Issue #1028: OCR can read pictures on pages that already have text (#1184) * Issue #1028: OCR can read pictures on pages that already have text Recognize Text skipped every page with text, so a picture of text on a page that also has real text never became searchable. The engine and the ocr_recognize tool already had skip_text_pages, but the desktop dialog never offered it, and turning it off read the page's own text again and added it a second time, so search found every word twice. - Engine: always collect the page's own glyph boxes; with skip_text_pages false, a recognised word at least half covered by them (an earlier OCR layer included) is left out. OcrPage counts those words as already_on_page. The default still skips pages with text. - Dialog: "Also recognize pages that already have text", kept with the other choices and used by Recognize text in multiple files too. - Tools: ocr_recognize reports already_on_page; ocr_recognize_files accepts skip_text_pages. - The two new labels in the 15 catalogs that translate the OCR dialog. - Synthetic tests (a picture of a sentence plus real text) for the engine, the tool and the dialog; the engine test fails with the filter turned off. Unit tests for the coverage check, including NaN, infinite, inverted and overlapping boxes. - parity, docs/roadmap.md M10 row and the ROADMAP.md progress log. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqiZ658tGndQaHao5Q6mdm * Issue #1028: Czech labels for the OCR text-pages option The Czech catalog was completed (#1066) while #1028 was in review, and czech_covers_ui_literals requires every tl! literal: the two new Recognize Text labels were missing, so the test failed after the merge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqiZ658tGndQaHao5Q6mdm * Contributors: credit rp6186030 as Raj Patel in the About window Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqiZ658tGndQaHao5Q6mdm --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rp6186030 <25706807+rp6186030@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 40 ++++++------- ROADMAP.md | 3 +- contributors/people.toml | 4 ++ crates/automation/src/a11y.rs | 2 +- crates/automation/src/tools.rs | 7 ++- crates/automation/tests/automation.rs | 28 +++++++++ crates/engine/src/ocr.rs | 84 +++++++++++++++++++++++---- crates/engine/src/tests.rs | 43 ++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.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/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.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/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sl.tsv | 2 + crates/ui-egui/src/i18n/te.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/ocr_ui.rs | 18 +++++- crates/ui-egui/tests/ocr.rs | 51 +++++++++++++++- docs/roadmap.md | 3 +- parity/acrobat-features.toml | 6 +- 32 files changed, 286 insertions(+), 43 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 61729e35b..d50798bea 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ 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 = "1049c27ed90afac974f371c7e7e3bff94a2baebde2d820adc4c4c84e822e4cdd" +sha256 = "e0efc10007da1342d860f881ab64a074444942242b1f09c240418957326b75e7" # 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`). @@ -30,7 +30,7 @@ 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 = "c30f72a1e902ddee4cb902560aaa91dfd3567490fd64db972f74a64fdb9af62c" +sha256 = "9dc6614e3a5acbc4d56debdc2df00fcf5d27daa5c06666ff810ac93e8114defe" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ 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 = "ad2c35209b7dbcd366038e6a179d695937ef1c3e333f64eb803de738766fb3e9" +sha256 = "c63bcdd45a56a8d078c267601cffda9c665334fa30e8c66c37b2bd82507f1c95" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ 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 = "5d5456a0e51a3287ada0faec559dc137197159e64b4df8e7ee22ac8c563a9de9" +sha256 = "2b1acb2ac2efc93333b8d5164fabe61747df617b78b5aae3b42e84b1b2d4cca1" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ 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 = "f0b1e7ac0bf248cf09bf2d038e9224c24d2edb4c3b3ba92b3806f9384161e77b" +sha256 = "ad168121e1c1cce409b3b1fb4c90607b796e7e74313cb50766f85bd4f27d3444" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ 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 = "468443e23821e248c934e222b245e87f926c38786575b6290d33f959d39a20bf" +sha256 = "1102e092fa3412e80b88b6c8112009de0f6606a041f90f6e90511608a8ea042b" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ 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 = "575b7487137ef8c4b4ae33fc7bdbd50370fa28078e41727216046fdc5b89b81a" +sha256 = "78892ec90eca3a8ad335ad80e6444f36c07f7c350519b1c3008ec49b759dc49b" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ 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 = "30f526fc7a1c14737bbaea272f45f123ffe61107188fe74375bd51b4808c9575" +sha256 = "6c6098c12d6a9f2809d19f96b5c629da4e5a7130479a079104d6a2f932c4b482" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ 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 = "4e4d4e4e2f9d4d4be91ed2767a07640b626d83733093d12bff99d0a3986aa7d7" +sha256 = "9f2385b4c401dd65a6f3563006a36731700de8f5bd10947fb085aabbb89d92d4" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ 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 = "8404b7ae758b3cddb28efd8f27405d260cf4193e3fecf68ea9422aebd4d063b4" +sha256 = "315a0380974d0cac529a8b942732535af9fadcdda55c4516fe55c55f4d3d4346" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ 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 = "0ae768bcb0d7d3c24f5636d85df1532e626452ff97397312270379501a025c52" +sha256 = "e46be841e460e4c1ea6f5154d851de825219c78576229561361b01fafd1cde32" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ 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 = "7b0798851885f59105046e861f682f529f5666b0ef8edc3fb3ba6255459d80ff" +sha256 = "994bfe85b3e706a77e5e532c93b3f3e5c47335f65b922a64ad12fdb369103a58" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ 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 = "b1c44f5f1b6deadcdbe216019750f24beeac5533b6f519be4e361c97b57d5ac1" +sha256 = "5f0b5dcdc5687fafa1020e6833de317311643f314e9be87cf8d04eac03af415a" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ 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 = "9dc87fafc98f9d30c4bafa8014f19b32b0e83a0608b2202e647988abb4e6303d" +sha256 = "a9481a3890ff1c4fde5f4a84e7be3b31128aeeb38dc362b78c85302e6c1e8be7" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ 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 = "af1cf608a1561057c530d5716ea8aac27dacf9fe4ab49741743b980f587a1409" +sha256 = "33903082ed23eb12a72e2dc22a9328575f6247fcd486647dac0c3d4fb085c36b" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ 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 = "f0e5375a5ee6b1151e312ba333f1a9a9ce0c55b27928da9bd6cff1ba95b8a5a1" +sha256 = "a077c18dd048a4b5fbea6a7701aecf36bbdc32a3544912c88ec31dd81089b23b" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ 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 = "9663c11a1f02c55bafb37ed2b12fc4d8542a2f2ed1c6dfdefd0b1f0629208cb8" +sha256 = "c779eae2b90e165fa96c361a605917d37298d0dfcad2008d416a3f07c21e9723" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ 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 = "3bfd85b5f6d3ab75573fa7511487561d8ee8b37a69dbf361c5b142cd50fb38e6" +sha256 = "8514f239f5dc15123fea56ab9e857dc7cf92cde2b7bc48f68dd02517b8338eba" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ 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 = "e9f46d2738530d4114b9e0a3e3c51bbd2df9bc51cb84506c10f499baa566650d" +sha256 = "3c5db01e760a2aa3da3f77730d3fcec6e05d91bc9ac039ab7eed57e22341f065" [[asset]] path = "assets/icons/accessibility.svg" @@ -3339,4 +3339,4 @@ 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 = "855e743c3b21f4d8e158d30c5757ead4f2b6b8bf830c87115ae99e6d16da76a3" +sha256 = "7acc4770b7fbca14cd5605eb484fc56a7744693742a86c80166ea61a54eedf31" diff --git a/ROADMAP.md b/ROADMAP.md index 352b0ba25..561932a9b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,9 +2,9 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request); earlier: M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what @@ -140,6 +140,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-11 (M10, #1028):** Recognize Text can read pages that already have text: "Also recognize pages that already have text" in the dialog (remembered with the other choices, and used by Recognize text in multiple files) and `skip_text_pages: false` on `ocr_recognize` and, newly, `ocr_recognize_files`. A picture on such a page becomes searchable, and a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out, so nothing is found twice; `ocr_recognize` reports how many were left out as `already_on_page`. Before, the option existed only for agents and added the page's text a second time. The default still skips pages with text. Synthetic tests (a picture of a sentence plus real text) cover the engine, the tool and the dialog, and fail with the filter turned off. Still missing: reading only the picture areas (the whole page is read, as for a scan). Separately, `recognize_text_makes_a_scanned_page_searchable` fails on main wherever the models are installed (the model reads "quick" as "auick"). Estimates unchanged. - **2026-10-11 (M2, page-label prefixes):** The page-label readers bound the total size of decoded prefixes. Each range decodes its own prefix, and ranges may share one prefix object, so a small file could ask for a copy per range: within the 200,000-entry tree limit, about 200 MiB of copies of one 1,024-byte prefix. Inspection and Organize now refuse a tree whose decoded prefixes pass 4 MiB in total (`MAX_PREFIX_TOTAL_BYTES`), before any label is made, as the other tree limits do; relabelling such a tree changes nothing. Synthetic tests share one 1,000-byte prefix across 5,000 ranges and keep exactly 4 MiB of prefixes readable. Overall estimate unchanged. - **2026-10-11 (UI, checkboxes and radio buttons):** Checkboxes no longer look like radio buttons, and on the light theme neither is invisible. With the theme's 6 px widget radius egui drew a checkbox's 14 px box as a circle, so View ▸ Page display's Continuous scrolling and Show cover page read as two more layout choices; on the light theme an unticked box or an unselected radio button was white on white with no edge. Every checkbox (81) and radio button (79) now goes through `widgets::checkbox` / `widgets::radio` / `widgets::radio_value`: a 1 px outline (darker on hover), the plain fill on the light theme as dialogs already had, and for checkboxes a rounded-square box (3 px), keeping what the caller set (dialogs, the search panel). `clippy.toml` disallows `ui.checkbox`, `ui.radio`, `ui.radio_value`, `egui::Checkbox` and `egui::RadioButton` so new ones use them too. Estimates unchanged. - **2026-10-11 (M13, #971):** `text_find` now bounds each page's temporary match-range list to the remaining result limit plus one, which is enough to detect and return a continuation without allocating every match on a dense page. Added limited-search coverage; the existing end-to-end continuation test passes. The text cache byte budget and per-page extraction warnings remain open. diff --git a/contributors/people.toml b/contributors/people.toml index d167e9f8b..3d361819d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -104,6 +104,10 @@ sync_github_name = true # use the name on your public GitHub profile as displa [people.Losminek] sync_github_name = true +[people.rp6186030] +real_name = "Raj Patel" +sync_github_name = false + [people.yeniufuklar-com] real_name = "Ufuk Efe" display_name = "Ufuk Efe" diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index f23ee24f5..3e7a046a8 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -195,7 +195,7 @@ impl Automation { .iter() .map(|p| match &p.skipped { Some(why) => json!({ "page": p.page + 1, "skipped": why }), - None => json!({ "page": p.page + 1, "words": p.words.len(), "text": p.text() }), + None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), }) .collect(); Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0e0f5116e..0534759e2 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -951,7 +951,7 @@ pub fn tools() -> Vec { )), t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.") .with(schema(json!({ "enabled": { "type": "boolean" } }), &[])), - t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") + t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") .cmd("ocr.recognize") .with(schema( json!({ @@ -959,11 +959,11 @@ pub fn tools() -> Vec { "pages": pages("to recognise (default: all)"), "dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution pages are read at (default 300; a scanned page is read at most at its own resolution)." }, "language": { "type": "string", "enum": ["en"], "description": "Document language (default en)." }, - "skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true)." }, + "skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true). false also reads pictures on such pages, without adding their text again." }, }), &["doc"], )), - t("ocr_recognize_files", "Recognize text in multiple files", "Scan & OCR ▸ Recognize text ▸ In multiple files: read every page of each PDF in paths and write the searchable result into folder under the same name (pages that already have text are left alone). Returns, per file, the output path, word count and skipped pages, or the error.") + t("ocr_recognize_files", "Recognize text in multiple files", "Scan & OCR ▸ Recognize text ▸ In multiple files: read every page of each PDF in paths and write the searchable result into folder under the same name (pages that already have text are left alone unless skip_text_pages is false, as in ocr_recognize). Returns, per file, the output path, word count and skipped pages, or the error.") .cmd("ocr.recognize_batch") .with(schema( json!({ @@ -971,6 +971,7 @@ pub fn tools() -> Vec { "folder": { "type": "string" }, "dpi": { "type": "number", "minimum": 72, "maximum": 600 }, "language": { "type": "string", "enum": ["en"] }, + "skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true); see ocr_recognize." }, }), &["paths", "folder"], )), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 0e5da853f..c76117138 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3319,6 +3319,34 @@ fn ocr_tools_make_a_scan_searchable() { assert!(matches!(a.call("ocr_recognize", &json!({ "doc": scan, "language": "xx" })), Err(ToolError::InvalidArgs(_)))); } +/// #1028: `skip_text_pages: false` reads a picture on a page that has its own text, and the +/// page's own words are left out (counted) instead of being added a second time. +#[test] +fn ocr_recognize_reads_pictures_on_pages_with_text() { + let dir = workdir("ocr-hybrid"); + let mut a = auto(&dir); + if ok(&mut a, "ocr_status", json!({}))["available"] != true { + eprintln!("skipped: OCR models not installed"); + return; + } + let text = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Pictures of words become searchable" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({ "doc": text, "folder": "scan", "dpi": 150, "pages": [1] })); + let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path(); + let doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Invoice reference 4711", "at": [40, 500], "size": 24 })); + + let skipped = ok(&mut a, "ocr_recognize", json!({ "doc": doc })); + assert!(skipped["pages"][0]["skipped"].is_string(), "{skipped}"); + let r = ok(&mut a, "ocr_recognize", json!({ "doc": doc, "skip_text_pages": false })); + let page = &r["pages"][0]; + let added = page["text"].as_str().unwrap().to_lowercase(); + assert!(added.contains("searchable") && !added.contains("invoice"), "{r}"); + assert!(page["already_on_page"].as_u64().unwrap() >= 2, "{r}"); + let found = |a: &mut Automation, q: &str| ok(a, "text_find", json!({ "doc": doc, "query": q }))["count"].as_u64().unwrap(); + assert_eq!((found(&mut a, "invoice"), found(&mut a, "searchable")), (1, 1)); + assert!(matches!(a.call("ocr_recognize", &json!({ "doc": doc, "skip_text_pages": "no" })), Err(ToolError::InvalidArgs(_)))); +} + #[test] fn ocr_recognize_files_refuses_paths_outside_the_root() { let dir = workdir("ocr-files-root"); diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index cb245f14f..6bf6dc9c7 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -21,10 +21,30 @@ pub struct OcrSettings { /// Language code from [`LANGUAGES`] (the models read the Latin alphabet, whatever this says). pub language: String, /// Leave pages that already have text alone (Acrobat reports "page contains renderable - /// text" and skips them). + /// text" and skips them). When `false`, such pages are read too (a picture on a page that + /// also has text, #1028) and only the words that aren't already there are added. pub skip_text_pages: bool, } +/// A recognised word whose box is at least this much covered by the page's own text is that +/// text read again, not a word in a picture. +const ALREADY_ON_PAGE: f32 = 0.5; + +/// How much of `word` the `text` boxes cover, from 0 to 1. Boxes are `[x0, y0, x1, y1]` in page +/// view space. Overlaps between the text boxes are counted twice (glyphs rarely overlap), so the +/// sum is capped at the whole word. +fn covered(word: [f32; 4], text: &[[f32; 4]]) -> f32 { + // `max(0.0)` also turns a NaN side into 0: a degenerate box covers nothing. + let area = |[x0, y0, x1, y1]: [f32; 4]| (x1 - x0).max(0.0) * (y1 - y0).max(0.0); + let total = area(word); + if !total.is_finite() || total <= 0.0 { + return 0.0; + } + let inside: f32 = + text.iter().map(|g| area([word[0].max(g[0]), word[1].max(g[1]), word[2].min(g[2]), word[3].min(g[3])])).filter(|a| a.is_finite()).sum(); + (inside / total).min(1.0) +} + impl Default for OcrSettings { fn default() -> Self { OcrSettings { dpi: 300.0, language: "en".into(), skip_text_pages: true } @@ -36,6 +56,8 @@ impl Default for OcrSettings { pub struct OcrPage { pub page: usize, pub words: Vec, + /// Words recognised but left out because the page's own text already has them there. + pub already_on_page: usize, /// Why the page was not read, if it was skipped. pub skipped: Option, } @@ -88,13 +110,16 @@ impl OcrJob { break; } let Some(info) = self.infos.get(page) else { continue }; - let skip = |why: &str| OcrPage { page, words: Vec::new(), skipped: Some(why.into()) }; - if self.settings.skip_text_pages { - let t = r.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() }); - if t.text.as_ref().is_some_and(|t| !t.plain_text().trim().is_empty()) { - out.push(skip("the page already contains text")); - continue; - } + let skip = |why: &str| OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(why.into()) }; + // The boxes of the page's own text (an earlier OCR layer's included), in view space. + let existing: Vec<[f32; 4]> = r + .render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() }) + .text + .map(|t| t.glyphs.iter().filter(|g| !g.text.trim().is_empty()).map(|g| g.rect).collect()) + .unwrap_or_default(); + if self.settings.skip_text_pages && !existing.is_empty() { + out.push(skip("the page already contains text")); + continue; } // A scan is read at its own resolution (enlarging it only blurs the letters), at most // the chosen one. @@ -117,8 +142,11 @@ impl OcrJob { let [u, v] = info.view_to_user(x / scale, y / scale); [u as f64, v as f64] }; - let words = lines.iter().flat_map(|l| &l.words).map(|w| PlacedWord::place(w, to_user)).collect(); - out.push(OcrPage { page, words, skipped: None }); + // Words the page's own text already has are not added a second time. + let (fresh, already): (Vec<_>, Vec<_>) = + lines.iter().flat_map(|l| &l.words).partition(|w| covered(w.rect.map(|v| v / scale), &existing) < ALREADY_ON_PAGE); + let words = fresh.into_iter().map(|w| PlacedWord::place(w, to_user)).collect(); + out.push(OcrPage { page, words, already_on_page: already.len(), skipped: None }); } progress(total, total); out @@ -210,3 +238,39 @@ pub fn recognize_file( } Ok(FileResult { bytes: s.save_bytes(id).map_err(|e| e.to_string())?, pages }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// #1028: a recognised word over the page's own text is that text again; one in an empty + /// part of the page (a picture) is new. + #[test] + fn words_over_existing_text_count_as_already_there() { + // Glyph em boxes of "Total" at 12 pt, five letters side by side. + let glyphs: Vec<[f32; 4]> = (0..5).map(|i| [100.0 + 7.0 * i as f32, 50.0, 107.0 + 7.0 * i as f32, 62.0]).collect(); + // OCR's tight box around the same ink. + assert!(covered([100.5, 52.0, 134.0, 61.0], &glyphs) >= ALREADY_ON_PAGE); + // A word in a picture further down, and one beside the text on the same line. + assert_eq!(covered([100.0, 200.0, 140.0, 212.0], &glyphs), 0.0); + assert_eq!(covered([140.0, 52.0, 170.0, 61.0], &glyphs), 0.0); + // Touching the text's last letter is not enough. + assert!(covered([131.0, 52.0, 170.0, 61.0], &glyphs) < ALREADY_ON_PAGE); + // A page without text has nothing to cover a word. + assert_eq!(covered([100.5, 52.0, 134.0, 61.0], &[]), 0.0); + } + + #[test] + fn coverage_of_overlapping_or_broken_boxes_stays_between_zero_and_one() { + // A glyph drawn twice (fake bold) does not cover more than the whole word. + let twice = [[0.0, 0.0, 10.0, 10.0], [0.0, 0.0, 10.0, 10.0]]; + assert_eq!(covered([0.0, 0.0, 10.0, 10.0], &twice), 1.0); + // Empty, inverted and non-finite boxes cover nothing and are covered by nothing. + for word in [[5.0, 5.0, 5.0, 9.0], [9.0, 0.0, 1.0, 9.0], [f32::NAN, 0.0, 9.0, 9.0], [0.0, 0.0, f32::INFINITY, 9.0]] { + assert_eq!(covered(word, &twice), 0.0, "{word:?}"); + } + let broken = [[f32::NAN; 4], [f32::NEG_INFINITY, f32::NEG_INFINITY, f32::INFINITY, f32::INFINITY]]; + let c = covered([0.0, 0.0, 10.0, 10.0], &broken); + assert!((0.0..=1.0).contains(&c), "{c}"); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a10911ee9..30e0490d1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2175,6 +2175,49 @@ fn recognize_text_makes_a_scanned_page_searchable() { assert!(again[0].skipped.is_some()); } +/// #1028: a page with its own text and a picture of text. By default it is skipped, as before; +/// with `skip_text_pages: false` the picture is read and the page's own text is not added again. +#[test] +fn recognize_text_reads_a_picture_on_a_page_that_has_text() { + if !ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut s = Session::new().with_clock(|| 1_700_000_000); + let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap(); + let id = s.open("text.pdf", None, text, None).unwrap(); + let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap(); + let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap(); + let id = s.open("scan.pdf", None, scan, None).unwrap(); + // Real text in the empty lower part of the picture. + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let note = AddedText { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], text: "Invoice reference 4711".into(), size: 18.0, ..Default::default() }; + s.apply(id, Edit::AddText { page: 0, text: note }).unwrap(); + let count = |s: &Session, word: &str| page_texts(s, id)[0].to_lowercase().matches(word).count(); + assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 0)); + + let skipped = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); + assert!(skipped[0].skipped.is_some(), "pages with text are still skipped by default"); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add text"), "nothing was added"); + + let hybrid = ocr::OcrSettings { skip_text_pages: false, ..Default::default() }; + let found = s.recognize_text(id, &[], hybrid.clone()).unwrap(); + assert_eq!(found[0].skipped, None); + assert!(found[0].already_on_page >= 2, "the page's own words were recognised and left out: {:?}", found[0]); + let added = found[0].text().to_lowercase(); + assert!(added.contains("brown") && added.contains("lazy") && !added.contains("invoice"), "{added}"); + assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 1), "{:?}", page_texts(&s, id)); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Recognize text")); + + // Reading the page again adds nothing: every word is there now. + let again = s.recognize_text(id, &[], hybrid).unwrap(); + assert!(again[0].words.is_empty(), "{:?}", again[0]); + assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 1)); +} + /// A form whose scripts are custom JavaScript: total = price × qty (calculate, through a /// document-level function), shown with a custom format; qty is validated; a button script. fn scripted_form() -> Vec { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 252a2aa79..3dc542d8f 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1222,6 +1222,8 @@ Searchable Image (Exact) صورة قابلة للبحث (مطابقة) Adds invisible text over each word; the page image is not changed يضيف نصًا غير مرئي فوق كل كلمة؛ صورة الصفحة لا تتغير Downsample to تقليل العينات إلى + Also recognize pages that already have text التعرف على الصفحات التي تحتوي على نص أيضًا + Pictures on those pages are recognized too; words already in the page's text are not added again يُتعرَّف على الصور في هذه الصفحات أيضًا، ولا تُضاف مرة أخرى الكلمات الموجودة بالفعل في نص الصفحة Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. التعرف على النص غير مثبت: ملفات نماذجه مفقودة. أعد تثبيت PdfCraft، أو اضبط PDFCRAFT_MODELS على المجلد الذي يحتويها. Settings الإعدادات Optional اختياري diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 5290ffee7..664422e15 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -921,6 +921,8 @@ Searchable Image (Exact) Изображение с възможност за търсене (точно) Adds invisible text over each word; the page image is not changed Добавя невидим текст върху всяка дума; изображението на страницата не се променя Downsample to Намали разделителната способност до + Also recognize pages that already have text Разпознай и страници, които вече съдържат текст + Pictures on those pages are recognized too; words already in the page's text are not added again Изображенията на тези страници също се разпознават; думите, които вече са в текста на страницата, не се добавят отново Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. Разпознаването на текст не е инсталирано: файловете на моделите му липсват. Инсталирайте PdfCraft отново или задайте в PDFCRAFT_MODELS папката, която ги съдържа. Settings Настройки Protect Защита diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bebd1cac4..a76627fdb 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1291,6 +1291,8 @@ key Space Mezerník Searchable Image (Exact) Prohledávatelný obraz (přesný) Adds invisible text over each word; the page image is not changed Přidá nad každé slovo neviditelný text; obraz stránky se nezmění Downsample to Převzorkovat na + Also recognize pages that already have text Rozpoznat i stránky, které už obsahují text + Pictures on those pages are recognized too; words already in the page's text are not added again Rozpoznají se i obrázky na těchto stránkách; slova, která už jsou v textu stránky, se znovu nepřidají Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. Rozpoznávání textu není nainstalováno: chybí soubory jeho modelů. Přeinstalujte PdfCraft nebo nastavte PDFCRAFT_MODELS na složku, ve které jsou. Settings Nastavení Optional Volitelné diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 278fb7cce..dbc39b4dd 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1207,6 +1207,8 @@ Searchable Image (Exact) Durchsuchbares Bild (exakt) Adds invisible text over each word; the page image is not changed Legt unsichtbaren Text über jedes Wort; das Seitenbild bleibt unverändert Downsample to Herunterrechnen auf + Also recognize pages that already have text Auch Seiten erkennen, die bereits Text enthalten + Pictures on those pages are recognized too; words already in the page's text are not added again Bilder auf diesen Seiten werden ebenfalls erkannt; Wörter, die schon im Text der Seite stehen, werden nicht erneut hinzugefügt Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. Die Texterkennung ist nicht installiert: ihre Modelldateien fehlen. Installiere PdfCraft neu oder setze PDFCRAFT_MODELS auf den Ordner, der sie enthält. Settings Einstellungen Optional Optional diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3513d352c..fb998398c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -805,6 +805,8 @@ Searchable Image (Exact) Αναζητήσιμη εικόνα (ακριβής) Adds invisible text over each word; the page image is not changed Προσθέτει αόρατο κείμενο πάνω από κάθε λέξη, χωρίς να αλλάζει την εικόνα της σελίδας Downsample to Μείωση ανάλυσης σε + Also recognize pages that already have text Αναγνώριση και σελίδων που έχουν ήδη κείμενο + Pictures on those pages are recognized too; words already in the page's text are not added again Αναγνωρίζονται και οι εικόνες αυτών των σελίδων· οι λέξεις που υπάρχουν ήδη στο κείμενο της σελίδας δεν προστίθενται ξανά Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. Η αναγνώριση κειμένου δεν είναι εγκατεστημένη: λείπουν τα αρχεία μοντέλων. Εγκαταστήστε ξανά το PdfCraft ή ορίστε το PDFCRAFT_MODELS στον φάκελο που τα περιέχει. Planned · {m} Προγραμματισμένο · {m} Planned for milestone {m} — open to see what it will include Προγραμματισμένο για το ορόσημο {m} — ανοίξτε για να δείτε τι θα περιλαμβάνει diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9c9b36c56..af375cb79 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1215,6 +1215,8 @@ Searchable Image (Exact) Imagen con búsqueda (exacta) Adds invisible text over each word; the page image is not changed Añade texto invisible sobre cada palabra; la imagen de la página no cambia Downsample to Reducir resolución a + Also recognize pages that already have text Reconocer también las páginas que ya tienen texto + Pictures on those pages are recognized too; words already in the page's text are not added again También se reconocen las imágenes de esas páginas; las palabras que ya están en el texto de la página no se vuelven a añadir Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. El reconocimiento de texto no está instalado: faltan sus archivos de modelo. Reinstala PdfCraft o define PDFCRAFT_MODELS con la carpeta que los contiene. Settings Ajustes Optional Opcional diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 38d692675..a93298f90 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1202,6 +1202,8 @@ Searchable Image (Exact) Image recherchable (exacte) Adds invisible text over each word; the page image is not changed Ajoute du texte invisible sur chaque mot ; l'image de la page n'est pas modifiée Downsample to Sous-échantillonner à + Also recognize pages that already have text Reconnaître aussi les pages qui contiennent déjà du texte + Pictures on those pages are recognized too; words already in the page's text are not added again Les images de ces pages sont aussi reconnues ; les mots déjà présents dans le texte de la page ne sont pas ajoutés une seconde fois Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. La reconnaissance de texte n'est pas installée : ses fichiers de modèles sont absents. Réinstallez PdfCraft, ou définissez PDFCRAFT_MODELS sur le dossier qui les contient. Settings Réglages Optional Facultatif diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 101428e4a..7371499a8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1192,6 +1192,8 @@ Searchable Image (Exact) Kereshető kép (pontos) Adds invisible text over each word; the page image is not changed Láthatatlan szöveget helyez minden szóra; az oldalkép nem változik Downsample to Feloldás csökkentése erre + Also recognize pages that already have text A már szöveget tartalmazó oldalak felismerése is + Pictures on those pages are recognized too; words already in the page's text are not added again Az ezeken az oldalakon lévő képeket is felismeri; az oldal szövegében már szereplő szavakat nem adja hozzá újra Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. A szövegfelismerés nincs telepítve: hiányoznak a modellfájljai. Telepítsd újra a PdfCraftot, vagy állítsd a PDFCRAFT_MODELS változót az őket tartalmazó mappára. Settings Beállítások Optional Nem kötelező diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ba10d5ba9..2d0431adb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1218,6 +1218,8 @@ Searchable Image (Exact) Immagine ricercabile (esatta) Adds invisible text over each word; the page image is not changed Aggiunge testo invisibile sopra ogni parola; l'immagine della pagina non viene modificata Downsample to Sottocampiona a + Also recognize pages that already have text Riconosci anche le pagine che contengono già testo + Pictures on those pages are recognized too; words already in the page's text are not added again Vengono riconosciute anche le immagini di queste pagine; le parole già presenti nel testo della pagina non vengono aggiunte di nuovo Settings Impostazioni Optional Facoltativo Planned · {m} Pianificato · {m} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 717e3687c..1ba4098e6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1222,6 +1222,8 @@ Searchable Image (Exact) 検索可能な画像(元の画像を保持) Adds invisible text over each word; the page image is not changed 各単語に非表示のテキストを重ねます。ページ画像は変更しません Downsample to ダウンサンプル: + Also recognize pages that already have text テキストを含むページも認識 + Pictures on those pages are recognized too; words already in the page's text are not added again それらのページ上の画像も認識します。ページのテキストに既にある単語は再度追加されません Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. テキスト認識がインストールされていません。モデルファイルがありません。PdfCraft を再インストールするか、PDFCRAFT_MODELS にモデルファイルのあるフォルダーを設定してください。 Settings 設定 Optional 任意 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 0c76f370b..b84111681 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -1170,6 +1170,8 @@ Searchable Image (Exact) Іздеуге болатын сурет (дәл) Adds invisible text over each word; the page image is not changed Әр сөздің үстіне көрінбейтін мәтін қосады; бет суреті өзгермейді Downsample to Ажыратымдылықты төмендету + Also recognize pages that already have text Мәтіні бар беттерді де тану + Pictures on those pages are recognized too; words already in the page's text are not added again Бұл беттердегі суреттер де танылады; беттің мәтінінде бұрыннан бар сөздер қайта қосылмайды Settings Параметрлер Optional Міндетті емес Planned · {m} Жоспарланған · {m} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7b22aadc0..97a45c57a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1209,6 +1209,8 @@ Searchable Image (Exact) Obraz z możliwością wyszukiwania (dokładny) Adds invisible text over each word; the page image is not changed Dodaje niewidoczny tekst nad każdym słowem; obraz strony pozostaje bez zmian Downsample to Zmniejsz rozdzielczość do + Also recognize pages that already have text Rozpoznaj także strony, które zawierają już tekst + Pictures on those pages are recognized too; words already in the page's text are not added again Obrazy na tych stronach też są rozpoznawane; słowa, które już są w tekście strony, nie są dodawane ponownie Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. Rozpoznawanie tekstu nie jest zainstalowane: brakuje plików modeli. Zainstaluj PdfCraft ponownie lub ustaw PDFCRAFT_MODELS na folder zawierający te pliki. Settings Ustawienia Optional Opcjonalne diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0b3e174f3..c9e710ca8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -866,6 +866,8 @@ Searchable Image (Exact) Imagem pesquisável (Exata) Adds invisible text over each word; the page image is not changed Adiciona texto invisível sobre cada palavra; a imagem da página não é alterada Downsample to Reduzir amostragem para + Also recognize pages that already have text Reconhecer também as páginas que já contêm texto + Pictures on those pages are recognized too; words already in the page's text are not added again As imagens dessas páginas também são reconhecidas; as palavras que já estão no texto da página não são adicionadas novamente Settings Configurações Protect Proteger Advanced options Opções avançadas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 443c73db0..e71b9c8c9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1223,6 +1223,8 @@ Searchable Image (Exact) Доступное для поиска изображение (точное) Adds invisible text over each word; the page image is not changed Добавляет невидимый текст над каждым словом; изображение страницы не меняется Downsample to Уменьшить до + Also recognize pages that already have text Распознавать и страницы, на которых уже есть текст + Pictures on those pages are recognized too; words already in the page's text are not added again Изображения на этих страницах тоже распознаются; слова, которые уже есть в тексте страницы, повторно не добавляются Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. Распознавание текста не установлено: отсутствуют файлы моделей. Переустановите PdfCraft или задайте в PDFCRAFT_MODELS папку, в которой они лежат. Settings Настройки Optional Опционально diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index a5a465a0f..030c7e6d5 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -623,6 +623,8 @@ Downloaded {name} Preneseno: {name} Downloading attachments on the web arrives with M3.10 ({n} bytes ready) Prenos priponk v spletni različici pride z M3.10 (pripravljenih bajtov: {n}) Downsample to Zmanjšaj vzorčenje na + Also recognize pages that already have text Prepoznaj tudi strani, ki že vsebujejo besedilo + Pictures on those pages are recognized too; words already in the page's text are not added again Prepoznane bodo tudi slike na teh straneh; besede, ki so že v besedilu strani, ne bodo dodane znova Drag a rectangle around the area to copy Povlecite pravokotnik okoli območja, ki ga želite kopirati Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes Povlecite pravokotnik na strani, da jo obrežete; dvokliknite stran za nastavitev okvirjev strani Drag a rectangle to create a link; double-click a link to edit it Povlecite pravokotnik, da ustvarite povezavo; dvokliknite povezavo, da jo uredite diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 0f4d67aa7..cc778e7a9 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1195,6 +1195,8 @@ comment menu Edit సవరించు Searchable Image (Exact) వెతకగలిగే చిత్రం (యథాతథం) Adds invisible text over each word; the page image is not changed ప్రతి పదంపై కనిపించని టెక్స్ట్‌ను జోడిస్తుంది; పేజీ చిత్రం మారదు Downsample to ఈ రిజల్యూషన్‌కు తగ్గించు + Also recognize pages that already have text ఇప్పటికే టెక్స్ట్ ఉన్న పేజీలను కూడా గుర్తించు + Pictures on those pages are recognized too; words already in the page's text are not added again ఆ పేజీలలోని చిత్రాలు కూడా గుర్తించబడతాయి; పేజీ టెక్స్ట్‌లో ఇప్పటికే ఉన్న పదాలు మళ్లీ జోడించబడవు Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. టెక్స్ట్ గుర్తింపు ఇన్‌స్టాల్ కాలేదు: దాని మోడల్ ఫైల్‌లు లేవు. PdfCraftను మళ్లీ ఇన్‌స్టాల్ చేయండి, లేదా వాటిని కలిగి ఉన్న ఫోల్డర్‌కు PDFCRAFT_MODELSను సెట్ చేయండి. Settings సెట్టింగ్‌లు Optional ఐచ్ఛికం diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6db2ea5bb..59f999900 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1182,6 +1182,8 @@ Searchable Image (Exact) Aranabilir Resim (Tam) Adds invisible text over each word; the page image is not changed Her sözcüğün üzerine görünmez metin ekler; sayfa resmi değiştirilmez Downsample to Şu değere alt örnekle: + Also recognize pages that already have text Zaten metin içeren sayfaları da tanı + Pictures on those pages are recognized too; words already in the page's text are not added again Bu sayfalardaki resimler de tanınır; sayfanın metninde zaten bulunan sözcükler yeniden eklenmez Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. Metin tanıma yüklü değil: model dosyaları eksik. PdfCraft'ı yeniden yükleyin veya PDFCRAFT_MODELS ortam değişkenini bunları içeren klasöre ayarlayın. Settings Ayarlar Optional İsteğe bağlı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 592d3eba3..df9ac6b1d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1220,6 +1220,8 @@ Searchable Image (Exact) Зображення з пошуком (точне) Adds invisible text over each word; the page image is not changed Додає невидимий текст поверх кожного слова; зображення сторінки не змінюється Downsample to Знизити роздільну здатність до + Also recognize pages that already have text Розпізнавати й сторінки, на яких уже є текст + Pictures on those pages are recognized too; words already in the page's text are not added again Зображення на цих сторінках теж розпізнаються; слова, які вже є в тексті сторінки, повторно не додаються Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. Розпізнавання тексту не встановлено: бракує файлів моделей. Перевстановіть PdfCraft або задайте в PDFCRAFT_MODELS теку, у якій вони лежать. Settings Налаштування Optional Необов’язково diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0feed72ea..dc101053c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -928,6 +928,8 @@ Searchable Image (Exact) 可搜索图像(精确) Adds invisible text over each word; the page image is not changed 在每个词上添加不可见文本;页面图像保持不变 Downsample to 降采样至 + Also recognize pages that already have text 同时识别已有文本的页面 + Pictures on those pages are recognized too; words already in the page's text are not added again 这些页面上的图片也会被识别;页面文本中已有的单词不会重复添加 Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. 未安装文本识别:缺少模型文件。请重新安装 PdfCraft,或将 PDFCRAFT_MODELS 设置为包含这些文件的文件夹。 Settings 设置 Protect 保护 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4c2b5aa60..bf4833a50 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -876,6 +876,8 @@ Searchable Image (Exact) 可搜尋圖片(精確) Adds invisible text over each word; the page image is not changed 在每個詞上新增不可見文字;頁面圖片保持不變 Downsample to 降取樣至 + Also recognize pages that already have text 同時辨識已有文字的頁面 + Pictures on those pages are recognized too; words already in the page's text are not added again 這些頁面上的圖片也會被辨識;頁面文字中已有的字詞不會重複加入 Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. 未安裝文字辨識:缺少模型檔案。請重新安裝 PdfCraft,或將 PDFCRAFT_MODELS 設定為包含這些檔案的資料夾。 Settings 設定 Protect 保護 diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 5bde1c956..4228549ee 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -28,11 +28,20 @@ pub struct OcrDraft { pub language: String, /// "Downsample to" resolution. pub dpi: u32, + /// Also recognize pages that already have text (pictures on them; their text isn't added again). + pub text_pages: bool, } impl Default for OcrDraft { fn default() -> Self { - OcrDraft { pages: OcrPages::All, from: 1, to: 1, language: "en".into(), dpi: 300 } + OcrDraft { pages: OcrPages::All, from: 1, to: 1, language: "en".into(), dpi: 300, text_pages: false } + } +} + +impl OcrDraft { + /// The recognition settings these choices make. + pub fn settings(&self) -> OcrSettings { + OcrSettings { dpi: self.dpi as f32, language: self.language.clone(), skip_text_pages: !self.text_pages } } } @@ -110,6 +119,9 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo }); ui.end_row(); }); + ui.add_space(4.0); + ui.checkbox(&mut d.text_pages, tl!("Also recognize pages that already have text")) + .on_hover_text(tl!("Pictures on those pages are recognized too; words already in the page's text are not added again")); }); if !available { ui.label( @@ -152,7 +164,7 @@ impl PdfCraftApp { OcrPages::Current => vec![self.views[vi].current], OcrPages::Range => (d.from.saturating_sub(1)..d.to).collect(), }; - let settings = OcrSettings { dpi: d.dpi as f32, language: d.language.clone(), ..Default::default() }; + let settings = d.settings(); let Some(job) = self.session.ocr_job(id, &pages, settings) else { return }; let progress = Arc::new(Mutex::new(OcrProgress { total: job.pages.len(), ..Default::default() })); let p = progress.clone(); @@ -189,7 +201,7 @@ impl PdfCraftApp { return; } // The settings showing now, not when the folder arrives. - let settings = OcrSettings { dpi: self.ocr_draft.dpi as f32, language: self.ocr_draft.language.clone(), ..Default::default() }; + let settings = self.ocr_draft.settings(); #[cfg(not(target_arch = "wasm32"))] match self.export_dir_override.clone() { Some(d) => self.ocr_files_into(files, settings, d.into()), diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index a62661fd1..e4f0ac685 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -3,7 +3,7 @@ use egui_kittest::Harness; use egui_kittest::kittest::Queryable; -use pdfcraft_engine::{Session, export}; +use pdfcraft_engine::{AddedText, Edit, Session, export}; use pdfcraft_ui_egui::PdfCraftApp; /// A one-page PDF that is only a picture of a sentence. @@ -65,3 +65,52 @@ fn recognize_text_in_multiple_files_writes_searchable_copies() { assert!(bytes.len() > scan().len(), "an incremental update was appended"); } } + +/// #1028: "Also recognize pages that already have text" reads a picture on a page that has its own +/// text, without adding that text a second time. +#[test] +fn recognize_text_can_read_pictures_on_pages_with_text() { + let hybrid = { + let mut s = Session::new(); + let id = s.open("scan.pdf", None, std::sync::Arc::new(scan()), None).unwrap(); + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let note = + AddedText { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], text: "Invoice reference 4711".into(), size: 18.0, ..Default::default() }; + s.apply(id, Edit::AddText { page: 0, text: note }).unwrap(); + s.save_bytes(id).unwrap().to_vec() + }; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("hybrid.pdf", None, hybrid).unwrap(); + app.run_inline = true; + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("ocr.recognize")); + h.run_steps(2); + assert!(h.state().ocr_draft.settings().skip_text_pages, "pages with text are skipped by default"); + h.get_by_label("Also recognize pages that already have text").click(); + h.run_steps(2); + assert!(!h.state().ocr_draft.settings().skip_text_pages); + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + h.get_by_label("Recognize text").click(); + h.run_steps(3); + assert!(h.state().ocr_run.is_none(), "finished"); + let toast = h.state().toast.clone().unwrap().0; + assert!(toast.starts_with("Recognized ") && toast.ends_with("on 1 page"), "{toast}"); + let app = h.state(); + let id = app.active_ids().unwrap().1; + let doc = app.session.get(id).unwrap(); + assert_eq!(doc.can_undo(), Some("Recognize text")); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); + let text = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + let text = text.text.unwrap().plain_text().to_lowercase(); + assert_eq!((text.matches("invoice").count(), text.matches("scanned").count()), (1, 1), "{text}"); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index a059aa317..2477ea75d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request; earlier: direct Organize overview and page actions; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (M13/#971: bound per-page search match allocation; text cache byte budget and extraction warnings remain open) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; M6: respect existing form-font encodings; retain source fonts on appearance fallback; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -88,7 +89,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`). 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. 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). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, web printing | +| 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). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, web printing | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools).
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
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. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0caf933dc..77b82860a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6427,7 +6427,7 @@ status = "shipped" commands = ["ocr.recognize"] tools = ["ocr_recognize", "ocr_status"] evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/ocr.rs::recognize_text_dialog_adds_searchable_text"] -notes = "ocrs engine (MIT/Apache) with CC-BY-SA models fetched by `cargo xtask models`; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped." +notes = "ocrs engine (MIT/Apache) with CC-BY-SA models fetched by `cargo xtask models`; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)." [[feature]] id = "ocr.recognize-range" @@ -6522,8 +6522,8 @@ milestone = "M10" status = "shipped" commands = ["ocr.recognize"] tools = ["ocr_recognize"] -evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/automation.rs::ocr_tools_make_a_scan_searchable"] -notes = "Pages that already have text are skipped and reported (skip_text_pages false reads them anyway)." +evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/automation.rs::ocr_tools_make_a_scan_searchable", "crates/engine/src/tests.rs::recognize_text_reads_a_picture_on_a_page_that_has_text", "crates/automation/tests/automation.rs::ocr_recognize_reads_pictures_on_pages_with_text", "crates/ui-egui/tests/ocr.rs::recognize_text_can_read_pictures_on_pages_with_text"] +notes = "Pages that already have text are skipped and reported by default. The dialog's \"Also recognize pages that already have text\" (skip_text_pages false, also for multiple files) reads pictures on such pages (#1028); a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out and counted (already_on_page). Missing: recognising only the image areas (the whole page is still read, so it takes as long as a scan), and a word in a picture that sits under real text is treated as that text." [[feature]] id = "ocr.rotation-detection" From 377f8da36bf5112c2e23169211d703e7da4128b8 Mon Sep 17 00:00:00 2001 From: Earl Co Date: Mon, 12 Oct 2026 00:54:06 +0200 Subject: [PATCH 16/16] M14: choose Tools panel side in Preferences (#1130) * M14: choose Tools panel side in Preferences * Catalogs: Tools panel side in every interface language; refresh hashes * OCR dialog: the shared outlined checkbox (raw ui.checkbox is linted since #1173) --------- Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 40 ++++++++++++++--------------- crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/docking.rs | 38 +++++++++++++++++++++++++++ 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/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 | 14 ++++++++++ crates/ui-egui/src/lib.rs | 5 ++++ crates/ui-egui/src/ocr_ui.rs | 2 +- crates/ui-egui/tests/ui.rs | 20 +++++++++++++++ 27 files changed, 119 insertions(+), 21 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index d50798bea..ab2934838 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ 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 = "e0efc10007da1342d860f881ab64a074444942242b1f09c240418957326b75e7" +sha256 = "fbc7790b5e9afd591b3bfbe0582f004f345b42d988989d81fdf38f312331fa01" # 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`). @@ -30,7 +30,7 @@ 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 = "9dc6614e3a5acbc4d56debdc2df00fcf5d27daa5c06666ff810ac93e8114defe" +sha256 = "fc734b2f2530561f8630f5d7112a237201012789b1d9167d3068b4ddb7b22f43" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ 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 = "c63bcdd45a56a8d078c267601cffda9c665334fa30e8c66c37b2bd82507f1c95" +sha256 = "379d0dc6203104e0f07ea0ed1e74a365c8eec7f9cf2bf525078dac0cf58b6372" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ 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 = "2b1acb2ac2efc93333b8d5164fabe61747df617b78b5aae3b42e84b1b2d4cca1" +sha256 = "b416309efba61009d4ed92c932cf1b23bb4f3a53d0d7d35cf61bf9c3d7a011ef" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ 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 = "ad168121e1c1cce409b3b1fb4c90607b796e7e74313cb50766f85bd4f27d3444" +sha256 = "066efa560b0dc5554c85194dcfb3190e03fce75145d52151bdcf7734f1829f7e" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ 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 = "1102e092fa3412e80b88b6c8112009de0f6606a041f90f6e90511608a8ea042b" +sha256 = "9061bbb9b21008b4c312b5158ed3c338cfbcb3b563e9e57b81cf72d075c9eadf" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ 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 = "78892ec90eca3a8ad335ad80e6444f36c07f7c350519b1c3008ec49b759dc49b" +sha256 = "068211fb4b246e1cb0ecb66598101f1c974e0313e1323e6acd522817f7f3bb57" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ 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 = "6c6098c12d6a9f2809d19f96b5c629da4e5a7130479a079104d6a2f932c4b482" +sha256 = "a2228f72e63c83959ea0ee7b1641bcf31e13b3c58fab62caebbd50f8d8d5c231" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ 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 = "9f2385b4c401dd65a6f3563006a36731700de8f5bd10947fb085aabbb89d92d4" +sha256 = "8c2b1625fb84fa7c9279d2ca0d6a22c808514d6361cf3c547a7ee21c64d170ae" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ 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 = "315a0380974d0cac529a8b942732535af9fadcdda55c4516fe55c55f4d3d4346" +sha256 = "394d322e782343f95c3b9cce603a77b7e3a6625000540a17f45b55b7233e7662" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ 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 = "e46be841e460e4c1ea6f5154d851de825219c78576229561361b01fafd1cde32" +sha256 = "5c5c0420cf58767312b132b9f9cb3c23131b58b831feae3e47b421f1081fe60b" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ 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 = "994bfe85b3e706a77e5e532c93b3f3e5c47335f65b922a64ad12fdb369103a58" +sha256 = "1d130f3f7112ac99693c5048a96405f3371dbe893d5c618f2910b8e80b6be28c" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ 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 = "5f0b5dcdc5687fafa1020e6833de317311643f314e9be87cf8d04eac03af415a" +sha256 = "a6ff8309a391acc428cb67e834196e14919ab5cc0f2d9befcbfb532d2ec4ca82" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ 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 = "a9481a3890ff1c4fde5f4a84e7be3b31128aeeb38dc362b78c85302e6c1e8be7" +sha256 = "40391d7be25e2a4e59cb1419df733935e69dd728fb0a3404575edc6807f27924" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ 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 = "33903082ed23eb12a72e2dc22a9328575f6247fcd486647dac0c3d4fb085c36b" +sha256 = "27fa6fae5ad0005f90a13612aa1eb8189061110eab8d6b6e7a83899af54f13e9" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ 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 = "a077c18dd048a4b5fbea6a7701aecf36bbdc32a3544912c88ec31dd81089b23b" +sha256 = "f4e6217dfc370ffa947c07b8c23bfa7d8f3824d881d7d51062b471b95f378c82" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ 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 = "c779eae2b90e165fa96c361a605917d37298d0dfcad2008d416a3f07c21e9723" +sha256 = "c3f539ce99a39a6f7a89740da9b7aab9643014613bfef1891efe777dc5cf057a" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ 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 = "8514f239f5dc15123fea56ab9e857dc7cf92cde2b7bc48f68dd02517b8338eba" +sha256 = "1cf9e140b348f1e8ceb399a8f079cae392085d9f432e3e1b9d94cd1039a1c4b8" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ 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 = "3c5db01e760a2aa3da3f77730d3fcec6e05d91bc9ac039ab7eed57e22341f065" +sha256 = "9d52789a71fe9b0a7cc74cfd6e9e6d0a8b3d9b9a866922676e376f1431d1cae3" [[asset]] path = "assets/icons/accessibility.svg" @@ -3339,4 +3339,4 @@ 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 = "7acc4770b7fbca14cd5605eb484fc56a7744693742a86c80166ea61a54eedf31" +sha256 = "c5e3a75359a8faadb9efa9ac4ff31963cf9124c20269a1e21715bcabde5e1292" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bba7108c0..d73e9da55 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -698,6 +698,7 @@ impl Host for crate::PdfCraftApp { "mode": format!("{:?}", self.mode), "default_mode": self.default_mode, "left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null }, + "tools_side": if self.docking.tools_on_right() { "right" } else { "left" }, "right_panel": self.right.map(|r| format!("{r:?}")), "dialog": self.dialog.map(|d| format!("{d:?}")), "palette_open": self.palette_open, diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 9c90e4568..caf2d4c43 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -80,6 +80,28 @@ impl Default for Workspace { } impl Workspace { + /// Side of the Tools panel, including its saved position while closed. + pub fn tools_on_right(&self) -> bool { + let location = self + .layout + .location(&Panel::Tools) + .ok() + .or_else(|| self.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).map(|(_, location)| location.clone())); + location.is_some_and(|location| matches!(location.placement, Placement::Split(Zone::Right))) + } + + /// Place Tools at the outer left or right edge while preserving its open state. + pub fn set_tools_on_right(&mut self, right: bool) -> Result<(), String> { + let was_open = self.layout.contains(&Panel::Tools); + self.set_visible(Panel::Tools, true)?; + let anchor = if right && self.layout.contains(&Panel::Inspector) { Panel::Inspector } else { Panel::Canvas }; + self.apply(Action::Move { panel: Panel::Tools, anchor, placement: Placement::Split(if right { Zone::Right } else { Zone::Left }) })?; + if !was_open { + self.set_visible(Panel::Tools, false)?; + } + Ok(()) + } + pub fn validate(&self) -> Result<(), String> { self.layout.validate().map_err(|error| error.to_string())?; if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() { @@ -429,6 +451,22 @@ fn panel_limits(panel: &Panel) -> PanelLimits { mod tests { use super::*; + #[test] + fn tools_side_moves_to_both_edges_and_survives_closed_restore() { + let mut workspace = Workspace::default(); + assert!(!workspace.tools_on_right()); + workspace.set_tools_on_right(true).unwrap(); + assert!(workspace.tools_on_right()); + workspace.set_visible(Panel::Tools, false).unwrap(); + assert!(workspace.tools_on_right()); + workspace.set_tools_on_right(false).unwrap(); + assert!(!workspace.tools_on_right()); + assert!(!workspace.layout.contains(&Panel::Tools)); + workspace.set_visible(Panel::Tools, true).unwrap(); + assert!(!workspace.tools_on_right()); + workspace.validate().unwrap(); + } + #[test] fn exposed_respects_collapsed_stack_and_inactive_floating_tabs() { let mut workspace = Workspace { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 3dc542d8f..af045c283 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2273,3 +2273,4 @@ scroll speed Fast سريعة Greyscale تدرج رمادي Black and white أبيض وأسود This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… لا يقبل برنامج تشغيل هذه الطابعة إعدادات الطباعة على الوجهين من البرامج الأخرى، لذا يُطبَّق إعداده الخاص. غيّره من خصائص… + Tools panel side جانب لوحة الأدوات diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 664422e15..a87722788 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2215,3 +2215,4 @@ scroll speed Fast Висока Greyscale Нива на сивото Black and white Черно-бяло This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйверът на този принтер не приема настройки за двустранен печат от други програми и прилага собствената си настройка. Променете я от Свойства… + Tools panel side Страна на панела с инструменти diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a76627fdb..cc52011e4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2329,3 +2329,4 @@ scroll speed Fast Rychlá This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Ovladač této tiskárny nepřijímá nastavení oboustranného tisku z jiných programů, proto platí jeho vlastní nastavení. Změňte ho ve Vlastnosti… 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. Písmo tohoto pole neumí zobrazit „{c}“ ({code}), proto PdfCraft místo něj ukazuje ?. Hodnota se uloží a jiné prohlížeče PDF pole podle ní překreslí. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Písma, kterými PdfCraft píše, neumí zobrazit „{c}“ ({code}), proto se ve všech prohlížečích PDF zobrazí jako ?. Text se uloží. + Tools panel side Strana panelu nástrojů diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index dbc39b4dd..9513ca9ac 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2253,3 +2253,4 @@ scroll speed Fast Schnell Greyscale Graustufen Black and white Schwarzweiß This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Der Treiber dieses Druckers übernimmt keine Einstellungen für beidseitigen Druck von anderen Programmen, daher gilt seine eigene Einstellung. Ändern unter Eigenschaften… + Tools panel side Seite des Werkzeugbereichs diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index fb998398c..d2c9ba2a3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2271,3 +2271,4 @@ scroll speed Fast Γρήγορη This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Το πρόγραμμα οδήγησης αυτού του εκτυπωτή δεν δέχεται ρυθμίσεις εκτύπωσης διπλής όψης από άλλα προγράμματα, οπότε ισχύει η δική του ρύθμιση. Αλλάξτε τη στις Ιδιότητες… 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 επανασχεδιάζουν το πεδίο από αυτήν. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Οι γραμματοσειρές με τις οποίες γράφει το PdfCraft δεν μπορούν να σχεδιάσουν το «{c}» ({code}), γι' αυτό εμφανίζεται ως ? σε κάθε πρόγραμμα προβολής PDF. Το κείμενο αποθηκεύεται. + Tools panel side Πλευρά πλαισίου εργαλείων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index af375cb79..360f12ffb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2303,3 +2303,4 @@ scroll speed Fast Rápida Greyscale Escala de grises Black and white Blanco y negro This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… El controlador de esta impresora no acepta los ajustes de doble cara de otros programas, así que se aplica su propio ajuste. Cámbielo en Propiedades… + Tools panel side Lado del panel de herramientas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a93298f90..f703cc2ca 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2252,3 +2252,4 @@ scroll speed Fast Rapide Greyscale Niveaux de gris Black and white Noir et blanc This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Le pilote de cette imprimante n'accepte pas les réglages recto verso des autres programmes ; son propre réglage s'applique. Modifiez-le dans Propriétés… + Tools panel side Côté du panneau Outils diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7371499a8..7b0080f03 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2251,3 +2251,4 @@ scroll speed Fast Gyors Greyscale Szürkeárnyalatos Black and white Fekete-fehér This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… A nyomtató illesztőprogramja nem fogadja el más programoktól a kétoldalas nyomtatás beállítását, ezért a saját beállítása érvényes. Módosítás: Tulajdonságok… + Tools panel side Az Eszközök panel oldala diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2d0431adb..643b55b79 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2261,3 +2261,4 @@ scroll speed Fast Veloce Greyscale Scala di grigi Black and white Bianco e nero This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Il driver di questa stampante non accetta le impostazioni fronte-retro da altri programmi, quindi vale la sua impostazione. Modificala in Proprietà… + Tools panel side Lato del pannello Strumenti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ba4098e6..55f8a961b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2252,3 +2252,4 @@ scroll speed Fast 速い Greyscale グレースケール Black and white 白黒 This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… このプリンターのドライバーは他のプログラムからの両面印刷の設定を受け付けないため、ドライバー自身の設定が使われます。変更先: プロパティ… + Tools panel side ツールパネルの位置 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index b84111681..ed4ecb045 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2253,3 +2253,4 @@ scroll speed Fast Жылдам This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Бұл принтердің драйвері басқа бағдарламалардан екі жақты басып шығару баптауларын қабылдамайды, сондықтан өз баптауы қолданылады. Оны мына жерден өзгертіңіз: Сипаттар… 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 қарау құралдары өрісті соған қарап қайта салады. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft жазатын қаріптер «{c}» ({code}) таңбасын сала алмайды, сондықтан ол барлық PDF қарау құралдарында ? болып көрінеді. Мәтін сақталады. + Tools panel side Құралдар панелінің жағы diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 97a45c57a..83ce917a9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2235,3 +2235,4 @@ scroll speed Fast Szybka Greyscale Skala szarości Black and white Czarno-biały This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Sterownik tej drukarki nie przyjmuje ustawień druku dwustronnego z innych programów, więc obowiązuje jego własne ustawienie. Zmień je w oknie Właściwości… + Tools panel side Strona panelu narzędzi diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c9e710ca8..f0248695e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2266,3 +2266,4 @@ scroll speed Fast Rápida Greyscale Escala de cinza Black and white Preto e branco This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… O driver desta impressora não aceita configurações de frente e verso de outros programas, então vale a configuração dele. Altere em Propriedades… + Tools panel side Lado do painel Ferramentas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e71b9c8c9..c1ecd6212 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2165,3 +2165,4 @@ scroll speed Fast Высокая Greyscale Оттенки серого Black and white Чёрно-белый This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйвер этого принтера не принимает настройки двусторонней печати от других программ, поэтому действует его собственная настройка. Измените её в окне Свойства… + Tools panel side Сторона панели инструментов diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 030c7e6d5..138316657 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2260,3 +2260,4 @@ scroll speed Fast Hitro This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Gonilnik tega tiskalnika ne sprejema nastavitev obojestranskega tiskanja iz drugih programov, zato velja njegova lastna nastavitev. Spremenite jo v Lastnosti… 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. Pisava tega polja ne more prikazati »{c}« ({code}), zato PdfCraft namesto njega prikaže ?. Vrednost se shrani, drugi pregledovalniki PDF pa polje znova narišejo iz nje. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Pisave, s katerimi piše PdfCraft, ne morejo prikazati »{c}« ({code}), zato se v vseh pregledovalnikih PDF prikaže kot ?. Besedilo se shrani. + Tools panel side Stran podokna z orodji diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index cc778e7a9..0c4279208 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2168,3 +2168,4 @@ scroll speed Fast వేగంగా Original అసలు Greyscale గ్రేస్కేల్ Black and white నలుపు తెలుపు + Tools panel side టూల్స్ ప్యానెల్ వైపు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 59f999900..85f7e6616 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2251,3 +2251,4 @@ scroll speed Fast Hızlı This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Bu yazıcının sürücüsü diğer programlardan gelen çift taraflı yazdırma ayarlarını kabul etmez, bu yüzden kendi ayarı geçerlidir. Değiştirmek için: Özellikler… 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. Bu alanın yazı tipi “{c}” ({code}) karakterini çizemiyor, bu yüzden PdfCraft yerine ? gösteriyor. Değer kaydedilir ve diğer PDF görüntüleyiciler alanı ondan yeniden çizer. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft'ın yazdığı yazı tipleri “{c}” ({code}) karakterini çizemiyor, bu yüzden her PDF görüntüleyicide ? olarak görünür. Metin kaydedilir. + Tools panel side Araçlar panelinin yeri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index df9ac6b1d..719b9304b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2274,3 +2274,4 @@ scroll speed Fast Швидка Greyscale Відтінки сірого Black and white Чорно-білий This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйвер цього принтера не приймає налаштування двобічного друку від інших програм, тому діє його власне налаштування. Змініть його у вікні Властивості… + Tools panel side Бік панелі інструментів diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index dc101053c..2c706c98a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2216,3 +2216,4 @@ scroll speed Fast 快 Greyscale 灰度 Black and white 黑白 This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… 此打印机的驱动程序不接受其他程序的双面打印设置,因此使用其自身设置。更改位置:属性… + Tools panel side 工具面板位置 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bf4833a50..62d8f9459 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2129,3 +2129,4 @@ scroll speed Fast 快 Greyscale 灰階 Black and white 黑白 This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… 此印表機的驅動程式不接受其他程式的雙面列印設定,因此使用其自身設定。變更位置:屬性… + Tools panel side 工具面板位置 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 2412651ad..95f36911c 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -278,6 +278,20 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok .small() .color(t.text_muted), ); + ui.horizontal(|ui| { + ui.label(tl!("Tools panel side")); + let right = app.docking.tools_on_right(); + if ui.selectable_label(!right, tl!("Left")).clicked() + && let Err(error) = app.docking.set_tools_on_right(false) + { + app.notify(error); + } + if ui.selectable_label(right, tl!("Right")).clicked() + && let Err(error) = app.docking.set_tools_on_right(true) + { + app.notify(error); + } + }); ui.add_space(8.0); crate::appearance::preferences_section(ui, app, t); ui.add_space(8.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8b3208523..15ee7f408 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1948,6 +1948,11 @@ impl PdfCraftApp { self.view_defaults = self.view_defaults.with_zoom(value).ok_or("default-zoom must be fit-width, fit-page or a percentage from 8 to 6400")?; } + ("tools-side", _) => self.docking.set_tools_on_right(match value { + "left" => false, + "right" => true, + _ => return Err("tools-side must be left or right".into()), + })?, ("organize", Some(v)) => v.organize = value != "off", // `--grid-zoom 150`: the size of the pages in the organize grid, in percent. ("grid-zoom", Some(v)) => { diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index 4228549ee..a10cb0b0a 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -120,7 +120,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.end_row(); }); ui.add_space(4.0); - ui.checkbox(&mut d.text_pages, tl!("Also recognize pages that already have text")) + ui.add(crate::widgets::checkbox(&mut d.text_pages, tl!("Also recognize pages that already have text"))) .on_hover_text(tl!("Pictures on those pages are recognized too; words already in the page's text are not added again")); }); if !available { diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 7b42f459d..8b8dc999c 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -37,6 +37,26 @@ fn home_shows_welcome_and_tools() { h.get_by_label("Open file"); } +#[test] +fn tools_panel_side_preference_moves_and_persists() { + use pdfcraft_ui_egui::Dialog; + + let mut h = harness(|_| {}); + h.state_mut().dialog = Some(Dialog::Preferences); + h.run_steps(3); + h.get_by_label("Tools panel side"); + h.get_by_label("Right").click(); + h.run_steps(3); + assert!(h.state().docking.tools_on_right()); + let saved = h.state().persist(); + let mut restored = PdfCraftApp::new(); + restored.restore(&saved); + assert!(restored.docking.tools_on_right()); + h.get_by_label("Left").click(); + h.run_steps(3); + assert!(!h.state().docking.tools_on_right()); +} + /// Every piece of text painted on the last frame: its whole text, whether it was cut with "…", /// and where it was drawn. fn painted_text(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, bool, egui::Rect)> {