From 4e79bbdbdfc277aadbc16a5648b4f7387e220786 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 08:51:23 -0400 Subject: [PATCH 01/23] CI: no macOS on PRs or main; macOS is built and tested by releases (#512) The org has 5 macOS slots in total (GitHub Team), shared by every repo's CI and release builds, and PR CI kept them full, so release builds queued behind it. macOS now runs only in release.yml; a release that fails on macOS is fixed and rebuilt. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f2730a3e7..821b7de74e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,12 +23,14 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + # No macOS on PRs, main or nightly: the org's 5 macOS slots are kept for release builds, + # and release.yml builds macOS (a failing release is fixed and rebuilt). + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 # The optional craft-fonts build input (Japanese fonts), so the Japanese font tests run on - # Linux; macOS and Windows test the build without it. See craftrules standards/fonts.md. + # Linux; Windows tests the build without it. See craftrules standards/fonts.md. - name: Check out craft-fonts (Linux) if: runner.os == 'Linux' uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 From a9a7eea2cc8e37ff2a0458a9f47c5a5417573b11 Mon Sep 17 00:00:00 2001 From: Streetblock Date: Fri, 9 Oct 2026 15:13:46 +0200 Subject: [PATCH 02/23] M1: enforce decoded limits on unfiltered COS streams (#477) Check the effective output limit before cloning a stream with an empty decoder chain. Share the raw guard between tolerant and strict decoding, including empty filter arrays and already-applied Crypt filters. Add small regressions for exceeded, exact and zero-byte limits and keep filtered decoding behavior unchanged. Co-authored-by: OpenAI Codex --- ROADMAP.md | 4 ++- crates/cos/src/object.rs | 67 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e61995ad1b..6793596238 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -90,7 +90,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t | M | Milestone | Est. hours | Done | Remaining (h) | Notes | |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 92% | 2–4 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro | | M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | 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. | | M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | 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. Missing: Replace pages, page transitions, recovery on the web | @@ -125,6 +125,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run ## Log +- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%). + - **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged. - **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged. - **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%). diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 983b53736f..808fdef160 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -223,19 +223,29 @@ impl Stream { pub fn decoded_within(&self, max: usize) -> Result, CosError> { let chain = self.filters(); if chain.is_empty() { - return Ok(self.raw.as_ref().clone()); + return self.raw_within(max); } pdfcraft_filters::decode_tolerant(&chain, &self.raw, max.min(MAX_DECODED)).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string())) } - /// Decoded data; any corruption is an error. + /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. pub fn decoded_strict(&self) -> Result, CosError> { let chain = self.filters(); if chain.is_empty() { - return Ok(self.raw.as_ref().clone()); + return self.raw_within(MAX_DECODED); } pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string())) } + + fn raw_within(&self, max: usize) -> Result, CosError> { + let max = max.min(MAX_DECODED); + // An empty decoder chain still produces an owned output buffer: check its limit + // before cloning, including streams whose /Crypt filter was applied on load. + if self.raw.len() > max { + return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string())); + } + Ok(self.raw.as_ref().clone()) + } } #[derive(Clone, Debug, PartialEq)] @@ -391,4 +401,55 @@ mod tests { assert_eq!(keys, vec![b"B".to_vec(), b"A".to_vec()]); assert_eq!(d.int(b"B"), Some(3)); } + + #[test] + fn raw_streams_refuse_decoding_past_a_caller_limit() { + let stream = Stream::from_raw(Dict::new(), b"abc".to_vec()); + for max in [0, 2] { + assert!(matches!(stream.decoded_within(max), Err(CosError::Filter(_)))); + } + } + + #[test] + fn empty_filter_arrays_refuse_decoding_past_a_caller_limit() { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(Vec::new())); + let stream = Stream::from_raw(dict, b"abc".to_vec()); + assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_)))); + } + + #[test] + fn crypt_only_streams_refuse_decoding_past_a_caller_limit() { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("Crypt")); + let mut parms = Dict::new(); + parms.set(b"Name".to_vec(), Object::name("Identity")); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let stream = Stream::from_raw(dict, b"abc".to_vec()); + assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_)))); + } + + #[test] + fn identity_streams_preserve_bytes_within_a_caller_limit() { + let mut array = Dict::new(); + array.set(b"Filter".to_vec(), Object::Array(Vec::new())); + let mut crypt = Dict::new(); + crypt.set(b"Filter".to_vec(), Object::name("Crypt")); + for dict in [Dict::new(), array, crypt] { + let stream = Stream::from_raw(dict.clone(), b"abc".to_vec()); + assert_eq!(stream.decoded_within(3).unwrap(), b"abc"); + assert_eq!(stream.decoded_within(usize::MAX).unwrap(), b"abc"); + assert_eq!(stream.decoded().unwrap(), b"abc"); + assert_eq!(stream.decoded_strict().unwrap(), b"abc"); + assert!(Stream::from_raw(dict, Vec::new()).decoded_within(0).unwrap().is_empty()); + } + } + + #[test] + fn filtered_streams_preserve_the_caller_limit() { + let stream = Stream::flate(Dict::new(), b"abc"); + assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_)))); + assert_eq!(stream.decoded_within(3).unwrap(), b"abc"); + assert_eq!(stream.decoded_strict().unwrap(), b"abc"); + } } From 5c354023ab178ce7f153b8193f7d4e750d6e1fed Mon Sep 17 00:00:00 2001 From: Streetblock Date: Fri, 9 Oct 2026 15:13:56 +0200 Subject: [PATCH 03/23] M2: handle Unicode dates and preserve inspection fallback pages (#469) Read the date prefix at a checked UTF-8 boundary and leave malformed dates unchanged. Commit auxiliary metadata only after successful inspection so an escaped inspector panic cannot discard the renderer's page geometry. Add synthetic date-boundary, UTF16BE annotation and independent failure regressions. Co-authored-by: OpenAI Codex --- ROADMAP.md | 2 + crates/render/src/inspect.rs | 122 +++++++++++++++++++++++++++++++---- 2 files changed, 110 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6793596238..a7fc14b1f6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,6 +138,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run Newest first. One line per session: the date, what moved, and the new overall percentage. +- **2026-10-09 (community, document inspection):** Annotation dates preserve non-date Unicode text instead of panicking while opening; failed auxiliary structure inspection keeps renderer page geometry and reports a warning. Synthetic UTF16BE annotation, UTF8-boundary and independent panic regressions cover the fix. Overall estimate unchanged (about 30–35%). + - **2026-10-09 (community, Home pinned folders):** Home gets a Folders section: File ▸ Pin folder to Home… (or "Pin a folder…" on Home) pins up to 8 folders, and each lists its newest PDFs (name, how long ago, size; 8 at first, up to 100 with Show more), one click to open, Unpin to remove. Folders are read on a worker thread and listed again every few seconds while Home shows, so new scans appear without reopening anything; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder is described by where it is ("iCloud Drive", "OneDrive · Contoso", "Documents › Work") rather than its full path, so two folders called "Scans" can be told apart. Cloud drives work through their local synced folder; nothing is read through a provider's API. Settings keep only absolute paths, without duplicates. Command label translated in every catalog; Home strings in French, Spanish, Japanese and both Chinese catalogs. Not yet: subfolders, search inside a pinned folder, the web build. Overall estimate unchanged (about 30–35%). - **2026-10-09 (community, M14, Telugu):** Telugu (తెలుగు, `te`) as a complete catalog of 1,984 entries covering every command, All tools entry, dialog, panel and notice; `te` and `te_IN` locales follow it. Telugu text is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, storytold/craft-fonts#10), kept in the web build too, after the CJK and Arabic faces; egui shapes its conjuncts. Commands use the plain imperative and sentences the polite form. The craft-fonts revision pinned for releases has no Telugu face yet, so release builds show Telugu as replacement boxes until the pin moves. Overall estimate unchanged (about 30–35%). diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 938525e65a..03af9fa271 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -304,30 +304,35 @@ pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))), } let options = load_options(password); - let structure = catch_unwind(AssertUnwindSafe(|| match Document::load_mem_with_options(&bytes, options) { - Ok(doc) => { - let mut tmp = DocInfo::default(); - std::mem::swap(&mut tmp.pages, &mut info.pages); - Inspector::new(&doc).fill(&mut tmp); - Ok(tmp) - } - Err(e) => Err(e.to_string()), + inspect_structure(&mut info, |tmp| { + let doc = Document::load_mem_with_options(&bytes, options).map_err(|e| e.to_string())?; + Inspector::new(&doc).fill(tmp); + Ok(()) + }); + if info.pages.is_empty() { + return Err(OpenError::Invalid("the document has no pages".into())); + } + Ok(info) +} + +// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback. +fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) { + let structure = catch_unwind(AssertUnwindSafe(|| { + let mut tmp = DocInfo { pages: info.pages.clone(), ..Default::default() }; + fill(&mut tmp)?; + Ok::<_, String>(tmp) })); match structure { Ok(Ok(mut filled)) => { filled.file_size = info.file_size; filled.pdf_version = std::mem::take(&mut info.pdf_version); - info = filled; + *info = filled; } Ok(Err(e)) => info.warnings.push(format!("Some document structure (bookmarks, comments, fields) could not be read: {e}")), Err(p) => { info.warnings.push(format!("Document structure inspection crashed and was skipped: {}", crate::raster::panic_message(&p))); } } - if info.pages.is_empty() { - return Err(OpenError::Invalid("the document has no pages".into())); - } - Ok(info) } struct Inspector<'a> { @@ -955,7 +960,8 @@ fn alpha(n: usize) -> String { /// A PDF date (`D:20261001123000Z`) as "2026-10-01 12:30"; other strings unchanged. pub fn pretty_date(s: &str) -> String { let d = s.trim_start_matches("D:"); - if d.len() >= 12 && d[..12].bytes().all(|b| b.is_ascii_digit()) { + if let Some(d) = d.get(..12).filter(|d| d.bytes().all(|b| b.is_ascii_digit())) { + // Twelve ASCII digits make every slice below a UTF-8 character boundary. format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12]) } else { s.to_string() @@ -1106,6 +1112,94 @@ trailer << /Root 1 0 R >> ); } + #[test] + fn structure_failure_preserves_renderer_pages() { + for crash in [true, false] { + let mut info = DocInfo { + file_size: 321, + pdf_version: "1.7".into(), + pages: vec![PageInfo { width: 300.0, height: 200.0, label: "1".into(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 90 }], + ..Default::default() + }; + inspect_structure(&mut info, |tmp| { + tmp.pages.clear(); + tmp.title = Some("partially inspected".into()); + if crash { + panic!("synthetic structure inspection failure"); + } + Err("synthetic structure inspection failure".into()) + }); + + assert_eq!(info.pages.len(), 1); + let page = &info.pages[0]; + assert_eq!((page.width, page.height, page.label.as_str(), page.crop, page.rotation), (300.0, 200.0, "1", [10.0, 20.0, 210.0, 320.0], 90)); + assert_eq!(info.file_size, 321); + assert_eq!(info.pdf_version, "1.7"); + assert!(info.title.is_none()); + assert_eq!(info.warnings.len(), 1); + assert!(info.warnings[0].contains("synthetic structure inspection failure")); + assert_eq!(info.warnings[0].contains("crashed"), crash); + } + } + + #[test] + fn invalid_dates_with_unicode_are_unchanged() { + for input in ["", "D:", "D:20260930104", "D:202609x01045", "yesterday"] { + assert_eq!(pretty_date(input), input); + } + // Cover every position before the 12-byte prefix, including characters that + // straddle its end. None of these strings is an ASCII PDF date. + for character in ['é', '€', '😀'] { + for prefix_len in 0..12 { + let input = format!("D:{}{character}123456789012", "1".repeat(prefix_len)); + assert_eq!(pretty_date(&input), input, "{character} after {prefix_len} digits"); + } + } + } + + #[test] + fn unicode_annotation_date_does_not_prevent_opening() { + use lopdf::dictionary; + + let date = "D:12345678901éX"; + let mut encoded_date = vec![0xFE, 0xFF]; + encoded_date.extend(date.encode_utf16().flat_map(u16::to_be_bytes)); + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let annot_id = doc.add_object(dictionary! { + "Type" => "Annot", + "Subtype" => "Text", + "Rect" => vec![10.into(), 10.into(), 30.into(), 30.into()], + "M" => Object::String(encoded_date, lopdf::StringFormat::Hexadecimal), + }); + let page_id = doc.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], + "Annots" => vec![annot_id.into()], + }); + doc.objects.insert( + pages_id, + dictionary! { + "Type" => "Pages", + "Kids" => vec![page_id.into()], + "Count" => 1, + } + .into(), + ); + let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + doc.trailer.set("Root", catalog_id); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).expect("write synthetic fixture"); + + let info = inspect(Arc::new(bytes), None).expect("opens despite a non-date /M string"); + assert_eq!(info.pages.len(), 1); + assert_eq!((info.pages[0].width, info.pages[0].height), (200.0, 300.0)); + assert_eq!(info.annotations.len(), 1); + assert_eq!(info.annotations[0].modified.as_deref(), Some(date)); + assert!(info.warnings.is_empty(), "{:?}", info.warnings); + } + #[test] fn dates_are_prettified() { assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45"); From ff496918a173c4773543297572f67952766771a4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:14:04 +0200 Subject: [PATCH 04/23] M7: pick a Japanese fallback face that has every glyph of the edited text (#384) (#472) Replacing text in a serif line (Times New Roman, say) with Cyrillic failed with "Japanese fallback font has no glyph for U+0444". The Type 3 fallback took only the style's first face, Shippori Mincho, which has no Cyrillic, although BIZ UDMincho and BIZ UDPGothic ship alongside it. `document_japanese_fonts_for_style` lists every Japanese face in the existing order of preference, and `type3_font` uses the first one that has a glyph for every character. If none has them all, the preferred face still names the missing character, as before. --- crates/edit/src/tests.rs | 22 ++++++++++++++++++++++ crates/edit/src/text.rs | 28 ++++++++++++++++++++++++++-- crates/fonts/README.md | 3 +++ crates/fonts/src/craft.rs | 39 +++++++++++++++++++++++++++++---------- crates/fonts/src/lib.rs | 4 ++-- 5 files changed, 82 insertions(+), 14 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0a0830fdaf..6711ba4a14 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -414,6 +414,28 @@ fn japanese_fallback_reports_the_face_after_save_and_reopen() { } } +#[test] +fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() { + if without_craft_fonts("cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it") { + return; + } + let replacement = "Привет, world"; + let has_all = |f: &pdfcraft_fonts::CraftFont| replacement.chars().all(|ch| pdfcraft_fonts::japanese_glyph_from(f, ch).is_ok()); + if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.covers("Jpan") && has_all(f)) { + eprintln!("skipping: no Japanese fallback face has Cyrillic"); + return; + } + for paragraph in [false, true] { + let mut doc = styled_text_page("Times-Roman"); + if paragraph { + text::replace_block(&mut doc, 0, 0, replacement).unwrap(); + } else { + text::replace_line(&mut doc, 0, 0, replacement).unwrap(); + } + assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement); + } +} + #[test] fn japanese_paragraph_uses_unicode_type3_fallback() { if without_craft_fonts("japanese_paragraph_uses_unicode_type3_fallback") { diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index d8a2dd933f..bf74edfb51 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -570,9 +570,13 @@ fn no_japanese_font() -> EditError { ) } +/// The first of `faces` (best match first) that has a glyph for every one of `chars`, else the first. +fn fallback_face<'a>(faces: &[&'a CraftFont], chars: &[char], has_glyph: impl Fn(&CraftFont, char) -> bool) -> Option<&'a CraftFont> { + faces.iter().copied().find(|face| chars.iter().all(|ch| has_glyph(face, *ch))).or_else(|| faces.first().copied()) +} + fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crate::added::Family, bold: bool) -> Result { - let face = pdfcraft_fonts::document_japanese_font_for_style(family == crate::added::Family::Times, bold).ok_or_else(no_japanese_font)?; - let family = face.family; + let faces = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold); let mut chars = Vec::new(); for ch in text.chars() { if !chars.contains(&ch) { @@ -585,6 +589,10 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crat if chars.is_empty() { return Err(EditError::Invalid("replacement text is empty".into())); } + // The faces differ in coverage (e.g. of Cyrillic), so use the best face that has every + // character. When none has them all, the best face reports the character it lacks. + let face = fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(no_japanese_font)?; + let family = face.family; let mut codes = Vec::with_capacity(chars.len()); let mut charprocs = Dict::new(); let mut widths = Vec::with_capacity(chars.len()); @@ -1138,4 +1146,20 @@ mod tests { assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]); assert_eq!(wrap(" \tA B\r\nC ", 3.0, width), ["A B", "C"]); } + + #[test] + fn fallback_face_moves_on_when_the_best_face_lacks_a_character() { + static JPAN: &[&str] = &["Jpan"]; + let mincho = CraftFont { family: "Mincho", style: "Regular", scripts: JPAN, bytes: b"" }; + let gothic = CraftFont { family: "Gothic", style: "Regular", scripts: JPAN, bytes: b"" }; + let faces = [&mincho, &gothic]; + // Only the Gothic face has the Cyrillic letter. + let has = |face: &CraftFont, ch: char| ch.is_ascii() || face.family == "Gothic"; + assert_eq!(fallback_face(&faces, &['a', 'ф'], has).map(|f| f.family), Some("Gothic")); + // Characters the best face has keep it. + assert_eq!(fallback_face(&faces, &['a', 'b'], has).map(|f| f.family), Some("Mincho")); + // No face has them all: the best face stays, and names the missing glyph. + assert_eq!(fallback_face(&faces, &['a', 'ф'], |_, ch| ch.is_ascii()).map(|f| f.family), Some("Mincho")); + assert!(fallback_face(&[], &['a'], has).is_none()); + } } diff --git a/crates/fonts/README.md b/crates/fonts/README.md index 133b44ab14..0f59dd6b4b 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -33,6 +33,9 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes: keep Mincho. Type 3 paths and advances both come from that selected face. Missing weights fall back to a real Regular face, never synthetic bold; no italic face is supplied. The default `document_japanese_font` / `japanese_glyph` APIs still use regular document Mincho. +- `document_japanese_fonts_for_style`: every `Jpan` face in that order of preference, so the + editor can use the first one that has all of the replacement's glyphs (the faces differ in + coverage, e.g. of Cyrillic). - `SHIPPORI_MINCHO`: Shippori Mincho's bytes, or `None`. Generated Japanese Type 3 fonts record the fallback face's family and style in an indirect diff --git a/crates/fonts/src/craft.rs b/crates/fonts/src/craft.rs index 322d9b2f86..84bc0d04cf 100644 --- a/crates/fonts/src/craft.rs +++ b/crates/fonts/src/craft.rs @@ -96,22 +96,36 @@ pub fn document_japanese_font_for_style(serif: bool, bold: bool) -> Option<&'sta document_face(CRAFT_FONTS, serif, bold) } +/// Every `Jpan` face that can stand in for the document text, the best match for the style first +/// (the face [`document_japanese_font_for_style`] returns), then the others. Not every face has +/// every glyph (their coverage of e.g. Cyrillic differs), so a caller can move on to the next one. +/// Empty without craft-fonts. +pub fn document_japanese_fonts_for_style(serif: bool, bold: bool) -> Vec<&'static CraftFont> { + document_faces(CRAFT_FONTS, serif, bold) +} + fn document_face(faces: &[CraftFont], serif: bool, bold: bool) -> Option<&CraftFont> { + document_faces(faces, serif, bold).into_iter().next() +} + +fn document_faces(faces: &[CraftFont], serif: bool, bold: bool) -> Vec<&CraftFont> { let jpan = || faces.iter().filter(|f| f.covers("Jpan")); + let mut preferred = Vec::new(); if !serif { let style = if bold { "Bold" } else { "Regular" }; - if let Some(face) = jpan() - .find(|f| f.family == "BIZ UDPGothic" && f.style == style) - .or_else(|| jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == "Regular")) - { - return Some(face); + preferred.extend(jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == style)); + preferred.extend(jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == "Regular")); + } + for family in ["Shippori Mincho", "BIZ UDMincho"] { + preferred.extend(jpan().find(|f| f.family == family && f.style == "Regular")); + } + let mut out: Vec<&CraftFont> = Vec::new(); + for face in preferred.into_iter().chain(jpan().filter(|f| f.style == "Regular")).chain(jpan()) { + if !out.iter().any(|f| std::ptr::eq(*f, face)) { + out.push(face); } } - ["Shippori Mincho", "BIZ UDMincho"] - .iter() - .find_map(|family| jpan().find(|f| f.family == *family && f.style == "Regular")) - .or_else(|| jpan().find(|f| f.style == "Regular")) - .or_else(|| jpan().next()) + out } const fn str_eq(a: &str, b: &str) -> bool { @@ -191,6 +205,11 @@ mod tests { assert_eq!(document_face(&faces, true, true).unwrap().bytes, b"serif"); assert_eq!(document_face(&faces[..2], false, true).unwrap().bytes, b"sans"); assert_eq!(document_face(&faces[..1], false, true).unwrap().bytes, b"serif"); + // Every Japanese face is a candidate, the style's match first and none twice. + let order: Vec<&[u8]> = document_faces(&faces, false, true).iter().map(|f| f.bytes).collect(); + assert_eq!(order, [b"bold".as_slice(), b"sans", b"serif"]); + let order: Vec<&[u8]> = document_faces(&faces, true, false).iter().map(|f| f.bytes).collect(); + assert_eq!(order, [b"serif".as_slice(), b"sans", b"bold"]); assert!(document_face(&faces[3..], false, true).is_none()); assert!(document_face(&[], false, true).is_none()); } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 9c3ce6d148..1dc6ced27a 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -10,8 +10,8 @@ mod encodings; pub mod pdf; mod script; pub use craft::{ - CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_japanese_font, document_japanese_font_for_style, ui_arabic_fonts, ui_chinese_fonts, - ui_cjk_fonts, ui_japanese_fonts, ui_telugu_fonts, + CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_japanese_font, document_japanese_font_for_style, document_japanese_fonts_for_style, + ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_japanese_fonts, ui_telugu_fonts, }; pub use script::{GlyphError, GlyphOutline, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_outline}; From e93a6f255f08be2aa7a24d5d827ab6ee805a1d3e Mon Sep 17 00:00:00 2001 From: Hal Goff Date: Fri, 9 Oct 2026 14:14:13 +0100 Subject: [PATCH 05/23] Never crash: CMap parsing can't panic, overflow or run away; tokens need no spaces (#495) The font-metrics CMap parser (ToUnicode and embedded CID CMaps) split the stream on whitespace and sliced hex tokens as UTF-8 text: - a non-ASCII byte inside `<...>` sliced a character in half and panicked - `bfrange` arrays and `/Differences` incremented code counters past u32::MAX, and CID lookup added `c + (code - lo)` unchecked - 4 KB of overlapping `bfrange`s could ask for millions of map entries - legal CMaps with no space between tokens (`<0001><0070>`) mapped nothing, so their text was not found for editing A byte-level lexer now reads hex strings (whitespace inside allowed), brackets, names, literal strings and comments. Counters use checked or saturating arithmetic, array entries stop at the range's `hi`, and a CMap adds at most 1 Mi ToUnicode entries and 64 Ki codespace/CID ranges. A bad array entry still stands for its code, as before. Claude-Session: https://claude.ai/code/session_01VNJqS2dEQawHJn1qTRJiKp Co-authored-by: Claude Opus 5.5 --- crates/fonts/src/pdf.rs | 337 ++++++++++++++++++++++++++++++++-------- 1 file changed, 272 insertions(+), 65 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 88b77c061e..aeee43e1b1 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -68,12 +68,125 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option { o.and_then(|o| doc.resolve(o).as_dict().cloned()) } -fn hex_bytes(tok: &str) -> Option> { - let t = tok.trim_start_matches('<').trim_end_matches('>'); - if !t.len().is_multiple_of(2) || t.is_empty() { - return None; +/// Most entries a CMap may add, so a small stream of overlapping `bfrange`s can't take minutes +/// or gigabytes; a full 2-byte code space is 65 536. Entries past it are ignored. +const MAX_CMAP_ENTRIES: usize = 1 << 20; +/// Most codespace or CID ranges kept from a CMap; Adobe's largest published CMaps have a few +/// thousand. +const MAX_CMAP_RANGES: usize = 1 << 16; + +/// A token of a CMap stream (PostScript syntax, ISO 32000-2 §9.10.3). +#[derive(Debug, PartialEq)] +enum Tok<'a> { + /// A `<…>` string; `None` when it isn't an even number of hex digits. + Hex(Option>), + Open, + Close, + /// An operator, number or name (with its `/`), a `(…)` string or a `<<`/`>>`. + Word(&'a [u8]), +} + +/// Splits a CMap into tokens without assuming whitespace between them (`<01><0041>` is two +/// strings), skipping `%` comments. +struct Lexer<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Lexer<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + fn at(&self, i: usize) -> Option { + self.data.get(i).copied() + } +} + +fn is_cmap_space(b: u8) -> bool { + matches!(b, 0 | b'\t' | b'\n' | 0x0c | b'\r' | b' ') +} + +fn is_cmap_delimiter(b: u8) -> bool { + matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') +} + +impl<'a> Iterator for Lexer<'a> { + type Item = Tok<'a>; + + fn next(&mut self) -> Option> { + loop { + let b = self.at(self.pos)?; + if is_cmap_space(b) { + self.pos += 1; + } else if b == b'%' { + while self.at(self.pos).is_some_and(|c| c != b'\n' && c != b'\r') { + self.pos += 1; + } + } else { + break; + } + } + let start = self.pos; + let b = self.at(start)?; + self.pos += 1; + match b { + b'[' => Some(Tok::Open), + b']' => Some(Tok::Close), + b'<' | b'>' if self.at(self.pos) == Some(b) => { + self.pos += 1; + Some(Tok::Word(self.data.get(start..self.pos)?)) + } + b'<' => { + let mut digits = Vec::new(); + let mut valid = true; + loop { + match self.at(self.pos) { + // Unterminated: what's left is one bad string. + None => return Some(Tok::Hex(None)), + Some(b'>') => break, + Some(c) if is_cmap_space(c) => {} + Some(c) => match (c as char).to_digit(16) { + // A hex digit is below 16, so it fits a u8. + Some(d) => digits.push(d as u8), + None => valid = false, + }, + } + self.pos += 1; + } + self.pos += 1; + if !valid || digits.is_empty() || !digits.len().is_multiple_of(2) { + return Some(Tok::Hex(None)); + } + // `chunks` never yields an empty slice, so `p[0]` exists. + Some(Tok::Hex(Some(digits.chunks(2).map(|p| p[0] << 4 | p.get(1).copied().unwrap_or(0)).collect()))) + } + b'(' => { + // Literal strings nest and escape their parentheses; only skipping them matters. + let mut depth = 1usize; + while depth > 0 { + match self.at(self.pos) { + None => break, + Some(b'\\') => self.pos += 1, + Some(b'(') => depth += 1, + Some(b')') => depth -= 1, + _ => {} + } + self.pos += 1; + } + Some(Tok::Word(self.data.get(start..self.pos.min(self.data.len()))?)) + } + _ => { + // A name keeps its `/`; other delimiters (`)`, `>`, `{`, `}`) stand alone. + if b == b'/' || !is_cmap_delimiter(b) { + while self.at(self.pos).is_some_and(|c| !is_cmap_space(c) && !is_cmap_delimiter(c)) { + self.pos += 1; + } + } + Some(Tok::Word(self.data.get(start..self.pos)?)) + } + } } - (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).ok()).collect() } fn be(b: &[u8]) -> u32 { @@ -89,47 +202,64 @@ fn style_from_name(name: &str) -> (bool, bool) { ) } -/// Codespace ranges and CID mappings from an embedded CMap stream. +/// The next entry of a `begin… end…` section: `N` tokens, or `None` at its `end` operator or +/// the end of the stream. +fn entry<'a, const N: usize>(toks: &mut Lexer<'a>, end: &[u8]) -> Option<[Tok<'a>; N]> { + let mut out = Vec::with_capacity(N); + for _ in 0..N { + match toks.next()? { + Tok::Word(w) if w == end => return None, + t => out.push(t), + } + } + out.try_into().ok() +} + +fn number(t: &Tok) -> Option { + match t { + Tok::Word(w) => std::str::from_utf8(w).ok()?.parse().ok(), + _ => None, + } +} + +/// Codespace ranges and CID mappings from an embedded CMap stream, at most +/// [`MAX_CMAP_RANGES`] of each (every glyph searches them). #[allow(clippy::type_complexity)] fn parse_cmap(data: &[u8]) -> (Vec<(usize, Vec, Vec)>, Vec<(u32, u32, u32)>) { - let text = String::from_utf8_lossy(data); - let toks: Vec<&str> = text.split_whitespace().collect(); let (mut spaces, mut cids) = (Vec::new(), Vec::new()); - let mut i = 0; - while i < toks.len() { - match toks[i] { - "begincodespacerange" => { - i += 1; - while i + 1 < toks.len() && toks[i] != "endcodespacerange" { - if let (Some(lo), Some(hi)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) + let mut toks = Lexer::new(data); + while let Some(t) = toks.next() { + match t { + Tok::Word(b"begincodespacerange") => { + while let Some([lo, hi]) = entry(&mut toks, b"endcodespacerange") { + if let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi))) = (lo, hi) && lo.len() == hi.len() + && spaces.len() < MAX_CMAP_RANGES { spaces.push((lo.len(), lo, hi)); } - i += 2; } } - "begincidrange" => { - i += 1; - while i + 2 < toks.len() && toks[i] != "endcidrange" { - if let (Some(lo), Some(hi), Ok(c)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1]), toks[i + 2].parse::()) { - cids.push((be(&lo), be(&hi), c)); + Tok::Word(b"begincidrange") => { + while let Some([lo, hi, c]) = entry(&mut toks, b"endcidrange") { + if let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi)), Some(c)) = (&lo, &hi, number(&c)) + && cids.len() < MAX_CMAP_RANGES + { + cids.push((be(lo), be(hi), c)); } - i += 3; } } - "begincidchar" => { - i += 1; - while i + 1 < toks.len() && toks[i] != "endcidchar" { - if let (Some(code), Ok(c)) = (hex_bytes(toks[i]), toks[i + 1].parse::()) { - cids.push((be(&code), be(&code), c)); + Tok::Word(b"begincidchar") => { + while let Some([code, c]) = entry(&mut toks, b"endcidchar") { + if let (Tok::Hex(Some(code)), Some(c)) = (&code, number(&c)) + && cids.len() < MAX_CMAP_RANGES + { + cids.push((be(code), be(code), c)); } - i += 2; } } _ => {} } - i += 1; } spaces.sort_by_key(|s| s.0); (spaces, cids) @@ -312,7 +442,7 @@ impl Metrics { if self.cid_map.is_empty() { return code; } - self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c + (code - lo)) + self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo)) } /// The advance of `code` in text space per unit of font size. @@ -392,60 +522,66 @@ fn utf16(b: &[u8]) -> String { String::from_utf16_lossy(&units) } -/// `bfchar`/`bfrange` entries of a ToUnicode CMap. +/// `bfchar`/`bfrange` entries of a ToUnicode CMap, at most [`MAX_CMAP_ENTRIES`] of them. fn parse_to_unicode(data: &[u8], out: &mut HashMap) { - let text = String::from_utf8_lossy(data); - // Tokenise, keeping arrays' brackets as tokens. - let spaced = text.replace('[', " [ ").replace(']', " ] "); - let toks: Vec<&str> = spaced.split_whitespace().collect(); - let mut i = 0; - while i < toks.len() { - match toks[i] { - "beginbfchar" => { - i += 1; - while i + 1 < toks.len() && toks[i] != "endbfchar" { - if let (Some(c), Some(u)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) { + let mut budget = MAX_CMAP_ENTRIES; + let mut toks = Lexer::new(data); + while let Some(t) = toks.next() { + match t { + Tok::Word(b"beginbfchar") => { + while let Some([c, u]) = entry(&mut toks, b"endbfchar") { + if let (Tok::Hex(Some(c)), Tok::Hex(Some(u))) = (c, u) { + let Some(left) = budget.checked_sub(1) else { return }; + budget = left; out.insert(be(&c), utf16(&u)); } - i += 2; } } - "beginbfrange" => { - i += 1; - while i + 2 < toks.len() && toks[i] != "endbfrange" { - let (Some(lo), Some(hi)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) else { break }; + Tok::Word(b"beginbfrange") => { + while let Some([lo, hi, dst]) = entry(&mut toks, b"endbfrange") { + let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi))) = (lo, hi) else { break }; let (lo, hi) = (be(&lo), be(&hi)); - if toks[i + 2] == "[" { - let mut j = i + 3; - let mut code = lo; - while j < toks.len() && toks[j] != "]" { - if let Some(u) = hex_bytes(toks[j]) { - out.insert(code, utf16(&u)); + match dst { + // One destination per code, from `lo` up to `hi`. + Tok::Open => { + let mut code = (hi >= lo).then_some(lo); + for t in toks.by_ref() { + let Some(c) = code else { + if t == Tok::Close { + break; + } + continue; + }; + match t { + Tok::Close => break, + Tok::Hex(Some(u)) => { + let Some(left) = budget.checked_sub(1) else { return }; + budget = left; + out.insert(c, utf16(&u)); + } + // A bad entry still stands for its code. + _ => {} + } + code = c.checked_add(1).filter(|c| *c <= hi); } - code += 1; - j += 1; } - i = j + 1; - } else { - if let Some(u) = hex_bytes(toks[i + 2]) - && hi >= lo - && hi - lo < 65536 - { + Tok::Hex(Some(u)) if hi >= lo && hi - lo < 65536 => { let mut units: Vec = u.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect(); for code in lo..=hi { + let Some(left) = budget.checked_sub(1) else { return }; + budget = left; out.insert(code, String::from_utf16_lossy(&units)); if let Some(last) = units.last_mut() { *last = last.wrapping_add(1); } } } - i += 3; + _ => {} } } } _ => {} } - i += 1; } } @@ -484,13 +620,14 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> HashMap code = (*n).max(0) as u32, + // Out-of-range codes map nothing a simple font can show. + Object::Int(n) => code = u32::try_from((*n).max(0)).unwrap_or(u32::MAX), Object::Name(n) => { match glyph_unicode(&String::from_utf8_lossy(n)) { Some(c) => out.insert(code, c.to_string()), None => out.remove(&code), }; - code += 1; + code = code.saturating_add(1); } _ => {} } @@ -637,4 +774,74 @@ mod tests { assert_eq!(glyph_unicode("u1F600"), Some('😀')); assert_eq!(glyph_unicode("nonsense"), None); } + + fn to_unicode(cmap: &[u8]) -> HashMap { + let mut out = HashMap::new(); + parse_to_unicode(cmap, &mut out); + out + } + + #[test] + fn cmap_tokens_need_no_space_between_them() { + // Hex strings, brackets and operators may touch; comments and literal strings are skipped. + let cmap = b"/CIDSystemInfo << /Registry (Adobe (x) \\) beginbfchar) >> def % beginbfchar <0001> <0058>\n\ + 2 beginbfchar<0003><0020><0010><00 E9>endbfchar \ + 1 beginbfrange<0024><0026><0041>endbfrange 1 beginbfrange<0030><0031>[<0048><0069>]endbfrange"; + let map = to_unicode(cmap); + let mut got: Vec<_> = map.iter().map(|(c, u)| (*c, u.as_str())).collect(); + got.sort_unstable(); + assert_eq!(got, [(3, " "), (0x10, "é"), (0x24, "A"), (0x25, "B"), (0x26, "C"), (0x30, "H"), (0x31, "i")]); + let (spaces, cids) = parse_cmap(b"1 begincodespacerange<00>endcodespacerange 1 begincidrange<20><7E>1 endcidrange"); + assert_eq!(spaces, [(1, vec![0], vec![0xff])]); + assert_eq!(cids, [(0x20, 0x7e, 1)]); + } + + #[test] + fn malformed_cmaps_never_panic() { + // Non-ASCII bytes inside hex strings once split a UTF-8 character and panicked. + for cmap in [ + "1 beginbfchar <0é0> <0041> <0042> endbfchar".as_bytes(), + "1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(), + b"1 beginbfchar <0001> <004", + b"1 beginbfchar <123> <0041> <0002> <0003> endbfchar", + b"1 beginbfrange <0001> [<0041>] endbfrange", + b"1 beginbfrange <0001> <0002> [<0041> (", + b"1 beginbfrange <0001>", + b"\xff\xfe beginbfchar \x80<\xc3\xa9> <0041> endbfchar %", + ] { + to_unicode(cmap); + parse_cmap(cmap); + } + // Code counters at the top of the range stop instead of overflowing. + let map = to_unicode(b"1 beginbfrange [<0041> <0042> <0043>] endbfrange"); + assert_eq!(map.len(), 2); + assert_eq!(map.get(&u32::MAX).map(String::as_str), Some("B")); + // A malformed array entry keeps its place, so the next one maps the next code. + let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); + assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); + let mut m = Metrics::fallback(); + (_, m.cid_map) = parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange"); + assert_eq!(m.cid(5), u32::MAX); + let mut doc = Document::new_empty(); + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); + let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); + assert_eq!(unicode_map(&doc, &f, false).get(&u32::MAX).map(String::as_str), Some("B")); + } + + #[test] + fn a_cmap_adds_at_most_max_entries() { + // 200 ranges of 65 536 codes would be 13 million strings from 4 KB of stream. + let mut cmap = b"200 beginbfrange ".to_vec(); + for k in 0..200u32 { + cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); + } + cmap.extend(b"endbfrange"); + assert_eq!(to_unicode(&cmap).len(), MAX_CMAP_ENTRIES); + let mut many = b"begincidrange ".to_vec(); + for k in 0..MAX_CMAP_RANGES + 10 { + many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes()); + } + assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES); + } } From 966bd0ffce7b14be7f9575bd5454dc5a1807fdbb Mon Sep 17 00:00:00 2001 From: feraremag2680 Date: Fri, 9 Oct 2026 15:14:19 +0200 Subject: [PATCH 06/23] tests: pin the interface language so the suite passes outside English (#502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ui-egui integration tests drive the shell by its English labels, and `i18n::system_lang` returns `Lang::EN` under `cfg!(test)` so they can. That only covers the unit tests: an integration test links the library as it ships, where `cfg!(test)` is off, so `PdfCraftApp::new()` leaves `language` on `auto` and the shell is drawn in the system language. On a Spanish Windows eleven of the test binaries fail; a French, Russian, Chinese, Japanese, Portuguese, Czech or Telugu machine does the same. CI never sees it because its locale is English. `tests/theme.rs` and `tests/create.rs` already pin the option; the other 119 harnesses now do too, and the comment in `system_lang` says why, so a test copied from an existing one keeps the line. Also `i18n::tests::windows_display_language_query_returns_a_locale_tag` treated the return of `windows_ui_language()` as a single tag. It is the preferred-language list, one tag per line, as `first_supported` reads it, so on a Windows with two or more display languages the assertion tripped over the newline. It now checks the shape of every line. Co-authored-by: Fernando Areán Co-authored-by: Claude Opus 5 --- crates/ui-egui/src/i18n/mod.rs | 16 +++++++++----- crates/ui-egui/tests/accessibility.rs | 2 ++ crates/ui-egui/tests/actions.rs | 2 ++ crates/ui-egui/tests/bookmarks.rs | 1 + crates/ui-egui/tests/buttons.rs | 2 ++ crates/ui-egui/tests/commands.rs | 4 ++++ crates/ui-egui/tests/comments.rs | 2 ++ crates/ui-egui/tests/compare.rs | 4 ++++ crates/ui-egui/tests/content.rs | 1 + crates/ui-egui/tests/control.rs | 1 + crates/ui-egui/tests/create.rs | 5 +++++ crates/ui-egui/tests/document_links.rs | 1 + crates/ui-egui/tests/editing.rs | 12 +++++++++++ crates/ui-egui/tests/export.rs | 1 + crates/ui-egui/tests/fill_sign.rs | 7 ++++++ crates/ui-egui/tests/folders.rs | 4 ++++ crates/ui-egui/tests/forms.rs | 4 ++++ crates/ui-egui/tests/identity.rs | 2 ++ crates/ui-egui/tests/javascript.rs | 4 ++++ crates/ui-egui/tests/layer_actions.rs | 1 + crates/ui-egui/tests/links.rs | 1 + crates/ui-egui/tests/marks.rs | 1 + crates/ui-egui/tests/ocr.rs | 2 ++ crates/ui-egui/tests/pageboxes.rs | 1 + crates/ui-egui/tests/pdf_links.rs | 1 + crates/ui-egui/tests/perf.rs | 2 ++ crates/ui-egui/tests/pickers.rs | 1 + crates/ui-egui/tests/prepare.rs | 1 + crates/ui-egui/tests/print.rs | 1 + crates/ui-egui/tests/protect.rs | 1 + crates/ui-egui/tests/recovery.rs | 5 +++++ crates/ui-egui/tests/redact.rs | 1 + crates/ui-egui/tests/signing.rs | 4 ++++ crates/ui-egui/tests/stamps.rs | 3 +++ crates/ui-egui/tests/text_select.rs | 1 + crates/ui-egui/tests/theme.rs | 4 ++++ crates/ui-egui/tests/ui.rs | 30 +++++++++++++++++++++++--- crates/ui-egui/tests/updates.rs | 2 ++ crates/ui-egui/tests/view.rs | 10 +++++++++ crates/ui-egui/tests/viewer.rs | 4 ++++ crates/ui-egui/tests/xfa.rs | 1 + 41 files changed, 145 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index c6bd7cedff..8eb90b024d 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -218,7 +218,9 @@ pub fn lang_from_tag(tag: &str) -> Option { /// The system language (cached). English when it can't be determined. pub fn system_lang() -> Lang { - // Tests drive the UI by its English labels whatever the developer's locale is. + // Tests drive the UI by its English labels whatever the developer's locale is. This covers + // the unit tests; the integration tests link the library as it ships, where `cfg!(test)` is + // off, so each of their harnesses pins the `language` option to `en` instead. if cfg!(test) { return Lang::EN; } @@ -450,10 +452,14 @@ mod tests { #[cfg(target_os = "windows")] #[test] fn windows_display_language_query_returns_a_locale_tag() { - let tag = windows_ui_language().expect("Windows should report a display language"); - let tag = tag.trim(); - assert!(!tag.is_empty()); - assert!(tag.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric()))); + // One tag per line, as `first_supported` reads them: a machine with several display + // languages reports them all, so each line is checked rather than the list as a whole. + let list = windows_ui_language().expect("Windows should report a display language"); + let tags: Vec<&str> = list.lines().map(str::trim).filter(|t| !t.is_empty()).collect(); + assert!(!tags.is_empty(), "no display language in {list:?}"); + for tag in tags { + assert!(tag.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric())), "not a locale tag: {tag:?}"); + } } #[test] diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs index 93074a973a..b4be88417e 100644 --- a/crates/ui-egui/tests/accessibility.rs +++ b/crates/ui-egui/tests/accessibility.rs @@ -38,6 +38,7 @@ fn checking_fixing_skipping_and_reporting() { let d = dir.clone(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("notes.pdf", None, FIXTURE.to_vec()).unwrap(); app.export_dir_override = Some(d.to_string_lossy().into_owned()); app @@ -110,6 +111,7 @@ trailer << /Root 1 0 R >> fn setting_alternate_text_figure_by_figure() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("figures.pdf", None, FIGURES.to_vec()).unwrap(); app }); diff --git a/crates/ui-egui/tests/actions.rs b/crates/ui-egui/tests/actions.rs index a1ed7ed2e7..dd4da8d3d3 100644 --- a/crates/ui-egui/tests/actions.rs +++ b/crates/ui-egui/tests/actions.rs @@ -17,6 +17,7 @@ fn run_and_create_actions() { let d = dir.clone(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.run_inline = true; app.export_dir_override = Some(d.join("out").to_string_lossy().into_owned()); app.action_files_override = Some(vec![d.join("r.pdf").to_string_lossy().into_owned()]); @@ -49,6 +50,7 @@ fn run_and_create_actions() { h.get_by_label("1. Add watermark: DRAFT"); let saved = h.state().persist(); let mut fresh = PdfCraftApp::new(); + fresh.set_option("language", "en").unwrap(); fresh.restore(&saved); assert_eq!(fresh.custom_actions, h.state().custom_actions); h.get_by_label("Start").click(); diff --git a/crates/ui-egui/tests/bookmarks.rs b/crates/ui-egui/tests/bookmarks.rs index ea93ad3560..8288b046d8 100644 --- a/crates/ui-egui/tests/bookmarks.rs +++ b/crates/ui-egui/tests/bookmarks.rs @@ -17,6 +17,7 @@ trailer << /Root 1 0 R >> fn harness() -> Harness<'static, PdfCraftApp> { 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("pages.pdf", None, PAGES.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("panel", "bookmarks").unwrap(); diff --git a/crates/ui-egui/tests/buttons.rs b/crates/ui-egui/tests/buttons.rs index 9c9481d63e..3fae947d20 100644 --- a/crates/ui-egui/tests/buttons.rs +++ b/crates/ui-egui/tests/buttons.rs @@ -57,6 +57,7 @@ fn buttons_reset_navigate_and_print() { let path_str = path.to_string_lossy().into_owned(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("buttons.pdf", Some(path_str), fixture()).unwrap(); app.set_option("zoom", "150").unwrap(); app @@ -124,6 +125,7 @@ fn name_hidden(h: &Harness<'static, PdfCraftApp>) -> bool { fn hide_actions_hide_and_show_fields() { 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("show-hide.pdf", None, show_hide_fixture()).unwrap(); app.set_option("zoom", "150").unwrap(); app diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 55058b4fc4..27ac83042c 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -35,6 +35,7 @@ fn fixture(n: usize) -> Vec { fn harness() -> Harness<'static, PdfCraftApp> { 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("doc.pdf", None, fixture(3)).unwrap(); app }); @@ -70,6 +71,7 @@ fn every_registered_command_is_implemented() { continue; } let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); if spec.id.starts_with("form.") || spec.id == "comment.flatten" { app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); } else { @@ -139,6 +141,7 @@ fn every_registered_command_is_implemented() { #[test] fn disabled_commands_explain_themselves() { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); assert!(!app.execute("file.save")); assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Open a document first")); app.open_bytes("doc.pdf", None, fixture(2)).unwrap(); @@ -206,6 +209,7 @@ fn the_open_recent_menu_lists_files_and_opens_one() { let path = path.clone(); move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("doc.pdf", None, fixture(3)).unwrap(); app.recent.push(pdfcraft_ui_egui::RecentFile { name: "recent.pdf".into(), path, pages: 2, size: 0 }); app diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 51ae14d725..688a962ede 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -26,6 +26,7 @@ trailer << /Root 1 0 R >> fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("author", "Tester").unwrap(); @@ -539,6 +540,7 @@ fn hovering_a_comment_shows_its_author_and_text() { ); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("hover.pdf", None, pdf.into_bytes()).expect("opens"); app.set_option("left", "closed").unwrap(); // No Comments panel: the comment's text should only appear in the hover popup. diff --git a/crates/ui-egui/tests/compare.rs b/crates/ui-egui/tests/compare.rs index 2264f57f4e..a06d2b076c 100644 --- a/crates/ui-egui/tests/compare.rs +++ b/crates/ui-egui/tests/compare.rs @@ -13,6 +13,7 @@ fn compare_two_versions() { let v2 = s.create_from_text("t", "Delivery within three days. Returns accepted for a week.").unwrap().to_vec(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("v1.pdf", None, v1.clone()).unwrap(); app.open_bytes("v2.pdf", None, v2.clone()).unwrap(); app @@ -49,6 +50,7 @@ fn compare_panel_screenshot() { let v2 = s.create_from_text("t", "Delivery within three days. Returns accepted for a week.").unwrap().to_vec(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("v1.pdf", None, v1.clone()).unwrap(); app.open_bytes("v2.pdf", None, v2.clone()).unwrap(); app @@ -68,6 +70,7 @@ fn pdfa_dialog_verifies_and_converts() { let doc = Session::new().create_from_text("t", "Keep forever").unwrap().to_vec(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("keep.pdf", None, doc.clone()).unwrap(); app }); @@ -93,6 +96,7 @@ fn export_to_word_html_and_rtf() { let dir = std::env::temp_dir().join(format!("pdfcraft-office-ui-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("e.pdf", None, doc).unwrap(); for ext in ["docx", "html", "rtf"] { let out = dir.join(format!("e.{ext}")); diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index aec3900154..eefec1ce2a 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -9,6 +9,7 @@ use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; fn harness() -> Harness<'static, PdfCraftApp> { 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("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); app.set_option("zoom", "150").unwrap(); app diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index 634fac3612..4326e1e8e7 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -43,6 +43,7 @@ fn harness_pages(pages: usize) -> (Harness<'static, PdfCraftApp>, ControlClient) let s = slot.clone(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); *s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); app.open_bytes("doc.pdf", None, fixture(pages)).unwrap(); app diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index 9f5390bdd8..f8f0e33927 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -54,6 +54,7 @@ fn image_import_dialog_chooses_dpi_and_cancels() { #[test] fn opening_images_and_text_converts_them_to_new_pdfs() { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("photo.png", Some("/tmp/photo.png".into()), png()).unwrap(); app.open_bytes("notes.txt", None, b"first line\nsecond line".to_vec()).unwrap(); app.create_from_images(vec![("a.png".into(), png()), ("b.png".into(), png())]); @@ -77,6 +78,7 @@ fn reduce_file_size_writes_a_compact_copy() { let out2 = out.clone(); let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("notes.txt", None, "lorem ipsum ".repeat(500).into_bytes()).unwrap(); app.save_override = Some(out2.to_string_lossy().into_owned()); app @@ -100,6 +102,7 @@ fn reduce_file_size_writes_a_compact_copy() { fn clipboard_images_and_text_become_new_pdfs() { use pdfcraft_ui_egui::Clip; let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.create_from_clip(Clip::Image { width: 40, height: 20, rgba: [10u8, 20, 30, 255].repeat(40 * 20) }).unwrap(); app.create_from_clip(Clip::Text("Pasted\nlines".into())).unwrap(); let docs = app.session.docs(); @@ -121,6 +124,7 @@ fn the_pdf_optimizer_dialog_saves_an_optimized_copy() { let out2 = out.clone(); let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("notes.txt", None, "lorem ipsum ".repeat(500).into_bytes()).unwrap(); app.save_override = Some(out2.to_string_lossy().into_owned()); app @@ -211,6 +215,7 @@ fn multiple_files_open_as_one_document_in_the_page_grid() { #[test] fn multiple_files_that_cannot_be_converted_open_nothing() { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.use_files(pdfcraft_ui_egui::FilePurpose::CreateMultiple, vec![("report.docx".into(), b"PK\x03\x04".to_vec())]); assert!(app.views.is_empty()); assert!(app.toast.is_some()); diff --git a/crates/ui-egui/tests/document_links.rs b/crates/ui-egui/tests/document_links.rs index a866a916c0..1951d24fcf 100644 --- a/crates/ui-egui/tests/document_links.rs +++ b/crates/ui-egui/tests/document_links.rs @@ -44,6 +44,7 @@ fn fixture() -> Vec { fn harness() -> Harness<'static, PdfCraftApp> { 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("links.pdf", None, fixture()).unwrap(); app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 1073478233..99c0ec118b 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -37,6 +37,7 @@ fn fixture(n: usize) -> Vec { fn harness(pages: usize, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("doc.pdf", None, fixture(pages)).expect("fixture opens"); setup(&mut app); app @@ -395,6 +396,7 @@ fn save_prompt_stays_inside_the_screen_for_a_long_filename() { Vliek, Ed Sutherland, -- 5, 2024 -- McGraw-Hill Education (UK) Ltd -- isbn13 97815268.pdf"; let mut h = Harness::builder().with_size(egui::vec2(1365.0, 719.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes(name, None, fixture(1)).expect("fixture opens"); app.close_request = Some(CloseRequest::Tab(app.views[0].id)); app @@ -429,6 +431,7 @@ fn save_prompt_fits_the_smallest_window_whatever_the_name() { let start: String = name.chars().take(10).collect(); let mut h = Harness::builder().with_size(size).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes(&name, None, fixture(1)).expect("fixture opens"); app.close_request = Some(CloseRequest::Tab(app.views[0].id)); app @@ -818,6 +821,7 @@ fn columns_resize_and_the_layout_is_kept_in_the_settings() { assert!(widths[size_index] > 120.0, "{widths:?}"); let saved = h.state().persist(); let mut fresh = PdfCraftApp::new(); + fresh.set_option("language", "en").unwrap(); fresh.restore(&saved); assert_eq!(fresh.combine_columns, h.state().combine_columns); // Malformed settings give the default layout. @@ -1123,6 +1127,7 @@ fn save_pages_writes_what_the_grid_shows() { h.run_steps(4); assert!(!dirty(&h)); let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("saved.pdf", None, std::fs::read(&path).unwrap()).unwrap(); assert_eq!(texts_of(&app, 0), ["Page 1", "Page 3"]); } @@ -1194,6 +1199,7 @@ fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, p fn password_prompt_opens_and_security_tab_reports_the_details() { 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("secret.pdf", None, protected("pw", "owner", -1)).unwrap(); app }); @@ -1218,6 +1224,7 @@ fn password_prompt_opens_and_security_tab_reports_the_details() { fn restricted_documents_show_a_notice_and_block_page_changes() { 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("locked.pdf", None, protected("", "owner", 0b0100)).unwrap(); // opens without a password app.set_option("organize", "on").unwrap(); app @@ -1240,6 +1247,7 @@ fn restricted_documents_show_a_notice_and_block_page_changes() { #[test] fn replace_pages_dialog_swaps_page_content() { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("doc.pdf", None, fixture(3)).unwrap(); app.views[0].select_pages(&[1]); app.start_replace("other.pdf".into(), fixture(5)); @@ -1365,6 +1373,7 @@ fn source_font_fixture() -> Vec { fn open_source_font_fixture() -> Harness<'static, PdfCraftApp> { 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("fonts.pdf", None, source_font_fixture()).expect("font fixture opens"); app }) @@ -1465,6 +1474,7 @@ fn double_drawn() -> Vec { fn editing_a_double_drawn_line_replaces_every_copy() { 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("bold.pdf", None, double_drawn()).expect("opens"); app }); @@ -1497,6 +1507,7 @@ fn editing_existing_images_on_the_page() { image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("picture.png", None, png.clone()).expect("opens"); app }); @@ -1622,6 +1633,7 @@ fn dragging_a_paragraph_moves_it_and_its_edge_rewraps_it() { fn pages_panel(pages: usize) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("doc.pdf", None, fixture(pages)).expect("fixture opens"); app.set_option("panel", "pages").unwrap(); app diff --git a/crates/ui-egui/tests/export.rs b/crates/ui-egui/tests/export.rs index f90c009707..4b5a6a77ed 100644 --- a/crates/ui-egui/tests/export.rs +++ b/crates/ui-egui/tests/export.rs @@ -20,6 +20,7 @@ fn export_dialogs_write_images_and_text() { let d = dir.clone(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap(); app.export_dir_override = Some(d.to_string_lossy().into_owned()); app diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs index 54fa780daa..7888555e5e 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -21,6 +21,7 @@ fn harness_bytes(fixture: &[u8]) -> Harness<'static, PdfCraftApp> { let fixture = fixture.to_vec(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("form.pdf", None, fixture.clone()).unwrap(); app.set_option("left", "closed").unwrap(); app.set_option("zoom", "150").unwrap(); @@ -129,6 +130,7 @@ fn signing_draws_a_signature_once_and_places_it() { // The signature is remembered (persisted with the app's settings). let saved = h.state().persist(); let mut again = PdfCraftApp::new(); + again.set_option("language", "en").unwrap(); again.restore(&saved); assert!(again.signature.is_some()); } @@ -160,6 +162,7 @@ fn typed_signatures_and_initials() { // Both are remembered. let saved = h.state().persist(); let mut again = PdfCraftApp::new(); + again.set_option("language", "en").unwrap(); again.restore(&saved); assert_eq!(again.signature, h.state().signature); assert_eq!(again.initials, Some(pdfcraft_ui_egui::fill_sign::SavedSig::Typed("GH".into()))); @@ -214,6 +217,7 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() { click(&mut h, 40.0, 100.0); assert_eq!(items(&h).len(), 3); let mut again = PdfCraftApp::new(); + again.set_option("language", "en").unwrap(); again.restore(&h.state().persist()); assert_eq!(again.signature, Some(SavedSig::Typed("Grace Hopper".into()))); assert_eq!(again.initials, Some(SavedSig::Typed("GH".into()))); @@ -269,6 +273,7 @@ fn saved_signature_cards_remove_and_add_without_changing_the_document() { assert_eq!(h.state().signature, None); assert_eq!(h.state().initials, Some(SavedSig::Typed("AL".into()))); let mut again = PdfCraftApp::new(); + again.set_option("language", "en").unwrap(); again.restore(&h.state().persist()); assert_eq!(again.signature, None, "removal survives restart"); h.get_by_label("Add signature").click(); @@ -366,6 +371,7 @@ fn image_signatures_and_initials_can_be_imported_placed_and_remembered() { let settings = state.persist(); assert!(!settings.contains(&path.to_string_lossy().to_string()), "only the image is saved, never its source path"); let mut again = PdfCraftApp::new(); + again.set_option("language", "en").unwrap(); again.restore(&settings); assert_eq!(again.signature, state.signature); assert_eq!(again.initials, state.initials); @@ -800,6 +806,7 @@ fn image_import_cancel_clear_and_errors_preserve_saved_signatures() { assert!(h.state().signature_draft.image.is_none()); // Malformed settings are ignored while legacy typed/drawn values still restore. let mut again = PdfCraftApp::new(); + again.set_option("language", "en").unwrap(); again.restore(r#"{"signature_text":"Ada","signature_image":{"Image":"bm90IGFuIGltYWdl"},"initials":{"Image":"%%%"}}"#); assert_eq!(again.signature, Some(SavedSig::Typed("Ada".into()))); assert!(again.initials.is_none()); diff --git a/crates/ui-egui/tests/folders.rs b/crates/ui-egui/tests/folders.rs index bfc5196d14..0f9fe80bda 100644 --- a/crates/ui-egui/tests/folders.rs +++ b/crates/ui-egui/tests/folders.rs @@ -51,6 +51,7 @@ impl Drop for TempFolder { fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); // List folders on the frame that asks, not on a worker thread. app.run_inline = true; setup(&mut app); @@ -144,8 +145,10 @@ fn pinned_folders_survive_a_restart() { let dir = TempFolder::new("restart"); let folder = dir.path(); let mut a = PdfCraftApp::new(); + a.set_option("language", "en").unwrap(); assert!(a.pin_folder(&folder)); let mut b = PdfCraftApp::new(); + b.set_option("language", "en").unwrap(); b.restore(&a.persist()); assert_eq!(b.pinned.folders, [folder]); b.restore("{\"pinned_folders\": [5, \"relative/path\"]}"); @@ -197,6 +200,7 @@ fn show_more_reveals_the_older_pdfs() { #[test] fn pinning_is_limited_and_never_duplicates() { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); assert!(!app.pin_folder("relative/scans"), "a relative path is refused"); let root = if cfg!(windows) { "C:\\" } else { "/" }; let folders: Vec = (0..MAX_PINNED).map(|i| Path::new(root).join(format!("pinned-{i}")).to_string_lossy().into_owned()).collect(); diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 1fde4e1eb0..f4f3552dab 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -6,6 +6,7 @@ use pdfcraft_ui_egui::PdfCraftApp; fn harness() -> Harness<'static, PdfCraftApp> { 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("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); app.set_option("left", "closed").unwrap(); // The whole 300×400 pt page on screen. @@ -148,6 +149,7 @@ fn date_fields_offer_a_calendar() { /// The value of `name` in a saved PDF, read back by opening it in a fresh app. fn saved_value(path: &std::path::Path, name: &str) -> Vec { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("saved.pdf", None, std::fs::read(path).unwrap()).unwrap(); app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() } @@ -310,6 +312,7 @@ fn a_rejected_value_keeps_the_typing_and_stops_the_save() { let out = dir.join("saved.pdf"); 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("order.pdf", None, validated_form()).unwrap(); app.set_option("left", "closed").unwrap(); app.set_option("zoom", "150").unwrap(); @@ -356,6 +359,7 @@ fn a_value_refused_on_enter_keeps_save_in_the_same_frame_from_running() { let out = dir.join("saved.pdf"); 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("order.pdf", None, validated_form()).unwrap(); app.set_option("left", "closed").unwrap(); app.set_option("zoom", "150").unwrap(); diff --git a/crates/ui-egui/tests/identity.rs b/crates/ui-egui/tests/identity.rs index 87e6a1a797..9a4e3c38f0 100644 --- a/crates/ui-egui/tests/identity.rs +++ b/crates/ui-egui/tests/identity.rs @@ -21,6 +21,7 @@ trailer << /Root 1 0 R >> fn the_identity_name_signs_new_comments_and_is_remembered() { 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("doc.pdf", None, PDF.to_vec()).expect("opens"); app.set_option("author", "Tester").unwrap(); app.set_option("dialog", "preferences").unwrap(); @@ -62,6 +63,7 @@ fn the_identity_name_signs_new_comments_and_is_remembered() { assert_eq!(author(&h).as_deref(), Some("Grace Hopper")); // It survives a restart; an empty or missing name keeps the default, and a huge one is cut. let mut restored = PdfCraftApp::new(); + restored.set_option("language", "en").unwrap(); restored.restore(&h.state().persist()); assert_eq!(restored.comment_prefs.author, "Grace Hopper"); restored.restore(r#"{"author": " "}"#); diff --git a/crates/ui-egui/tests/javascript.rs b/crates/ui-egui/tests/javascript.rs index 19ad05a54c..a5c5a6aff9 100644 --- a/crates/ui-egui/tests/javascript.rs +++ b/crates/ui-egui/tests/javascript.rs @@ -33,6 +33,7 @@ fn form() -> Vec { fn harness() -> Harness<'static, PdfCraftApp> { 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("form.pdf", None, form()).unwrap(); app }); @@ -98,6 +99,7 @@ fn document_scripts_and_preferences() { assert!(!h.state().session.javascript()); let saved = h.state().persist(); let mut fresh = PdfCraftApp::new(); + fresh.set_option("language", "en").unwrap(); fresh.restore(&saved); assert!(!fresh.session.javascript(), "the preference is remembered"); } @@ -108,6 +110,7 @@ fn merge_data_files_into_a_spreadsheet() { std::fs::create_dir_all(&dir).unwrap(); let out = dir.join("report.csv"); let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.save_override = Some(out.to_string_lossy().into_owned()); app.merge_data_files(vec![("form.pdf".into(), form())]); assert_eq!(std::fs::read_to_string(&out).unwrap(), "greeting\n\n"); @@ -121,6 +124,7 @@ fn prepare_a_form_detects_fields_on_a_paper_form() { let paper = pdfcraft_engine::Session::new().create_from_text("t", "Name: ____________________\n\nPhone: ____________________").unwrap().to_vec(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("paper.pdf", None, paper.clone()).unwrap(); app }); diff --git a/crates/ui-egui/tests/layer_actions.rs b/crates/ui-egui/tests/layer_actions.rs index efa9a1e3a3..95b35ff234 100644 --- a/crates/ui-egui/tests/layer_actions.rs +++ b/crates/ui-egui/tests/layer_actions.rs @@ -38,6 +38,7 @@ fn fixture() -> Vec { fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("layers.pdf", None, fixture()).unwrap(); app.set_option("zoom", "150").unwrap(); setup(&mut app); diff --git a/crates/ui-egui/tests/links.rs b/crates/ui-egui/tests/links.rs index 9b5e3215d2..d3ee781966 100644 --- a/crates/ui-egui/tests/links.rs +++ b/crates/ui-egui/tests/links.rs @@ -9,6 +9,7 @@ use pdfcraft_ui_egui::{Dialog, PdfCraftApp}; fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); setup(&mut app); app }); diff --git a/crates/ui-egui/tests/marks.rs b/crates/ui-egui/tests/marks.rs index b19607c40d..0a216ba5f6 100644 --- a/crates/ui-egui/tests/marks.rs +++ b/crates/ui-egui/tests/marks.rs @@ -17,6 +17,7 @@ trailer << /Root 1 0 R >> fn harness() -> Harness<'static, PdfCraftApp> { 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("doc.pdf", None, FIXTURE.to_vec()).unwrap(); app }); diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index 8e9e977444..a62661fd1e 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -19,6 +19,7 @@ fn scan() -> Vec { fn recognize_text_dialog_adds_searchable_text() { 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("scan.pdf", None, scan()).unwrap(); app.run_inline = true; app @@ -52,6 +53,7 @@ fn recognize_text_in_multiple_files_writes_searchable_copies() { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.run_inline = true; app.export_dir_override = Some(dir.to_string_lossy().into_owned()); app.use_files(pdfcraft_ui_egui::FilePurpose::Ocr, vec![("one.pdf".into(), scan()), ("two.pdf".into(), scan())]); diff --git a/crates/ui-egui/tests/pageboxes.rs b/crates/ui-egui/tests/pageboxes.rs index 6ad018b7eb..865f127707 100644 --- a/crates/ui-egui/tests/pageboxes.rs +++ b/crates/ui-egui/tests/pageboxes.rs @@ -15,6 +15,7 @@ trailer << /Root 1 0 R >> fn harness() -> Harness<'static, PdfCraftApp> { 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("doc.pdf", None, FIXTURE.to_vec()).unwrap(); app.set_option("left", "closed").unwrap(); app.set_option("zoom", "150").unwrap(); diff --git a/crates/ui-egui/tests/pdf_links.rs b/crates/ui-egui/tests/pdf_links.rs index 8095ec10ca..a149b3d3ba 100644 --- a/crates/ui-egui/tests/pdf_links.rs +++ b/crates/ui-egui/tests/pdf_links.rs @@ -10,6 +10,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { // 60 fps steps, so two clicks fall inside egui's double-click window. let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); app.set_option("zoom", "150").unwrap(); app diff --git a/crates/ui-egui/tests/perf.rs b/crates/ui-egui/tests/perf.rs index b4a8e45d89..f30be048f9 100644 --- a/crates/ui-egui/tests/perf.rs +++ b/crates/ui-egui/tests/perf.rs @@ -100,6 +100,7 @@ fn scrolling_a_500_page_document_stays_within_the_frame_budget() { let t0 = std::time::Instant::now(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("big.pdf", None, bytes).expect("opens"); app }); @@ -124,6 +125,7 @@ fn panels_with_hundreds_of_items_stay_within_the_frame_budget() { let b = bytes.clone(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("big.pdf", None, b).expect("opens"); app.set_option("panel", panel).unwrap(); app diff --git a/crates/ui-egui/tests/pickers.rs b/crates/ui-egui/tests/pickers.rs index df8b1ae5ed..fb0103f4e6 100644 --- a/crates/ui-egui/tests/pickers.rs +++ b/crates/ui-egui/tests/pickers.rs @@ -45,6 +45,7 @@ fn harness(docs: &[usize]) -> Harness<'static, PdfCraftApp> { let docs = docs.to_vec(); let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); for (i, n) in docs.iter().enumerate() { app.open_bytes(&format!("doc{i}.pdf"), None, fixture(*n)).unwrap(); } diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index 3b1a5dc3dc..5f011fa8e6 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -9,6 +9,7 @@ use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; fn harness() -> Harness<'static, PdfCraftApp> { 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("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); app.set_option("zoom", "150").unwrap(); app diff --git a/crates/ui-egui/tests/print.rs b/crates/ui-egui/tests/print.rs index 94ab37ff48..53ad1b8653 100644 --- a/crates/ui-egui/tests/print.rs +++ b/crates/ui-egui/tests/print.rs @@ -7,6 +7,7 @@ use pdfcraft_ui_egui::{Dialog, PdfCraftApp}; fn harness() -> Harness<'static, PdfCraftApp> { 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("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); app }); diff --git a/crates/ui-egui/tests/protect.rs b/crates/ui-egui/tests/protect.rs index 714d509554..903b15105b 100644 --- a/crates/ui-egui/tests/protect.rs +++ b/crates/ui-egui/tests/protect.rs @@ -14,6 +14,7 @@ trailer << /Root 1 0 R >> fn harness() -> Harness<'static, PdfCraftApp> { 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("doc.pdf", None, FIXTURE.to_vec()).unwrap(); app }); diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs index 6f59bb1688..8cc9eebfc7 100644 --- a/crates/ui-egui/tests/recovery.rs +++ b/crates/ui-egui/tests/recovery.rs @@ -46,6 +46,7 @@ fn files_in(s: &RecoveryStore) -> usize { /// A session that edits a document, autosaves, and then "crashes" (is dropped). fn crashed_session(s: &RecoveryStore, bytes: Vec, password: Option<&str>, path: Option<&str>) { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.enable_recovery(s.clone()); match password { Some(pw) => { @@ -62,6 +63,7 @@ fn crashed_session(s: &RecoveryStore, bytes: Vec, password: Option<&str>, pa fn harness(s: RecoveryStore) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.enable_recovery(s); app }); @@ -103,6 +105,7 @@ fn discarding_removes_the_autosaves() { fn saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents() { let s = store("save"); let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.enable_recovery(s.clone()); app.open_bytes("a.pdf", None, fixture(2)).unwrap(); app.autosave_now(); @@ -130,6 +133,7 @@ fn quitting_cleanly_leaves_nothing_behind() { let s = s.clone(); move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.enable_recovery(s); app.open_bytes("q.pdf", None, fixture(2)).unwrap(); app @@ -206,6 +210,7 @@ fn control_click_effects_are_visible_when_the_reply_arrives() { let (slot2, s2) = (slot.clone(), s.clone()); let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); *slot2.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); app.enable_recovery(s2); app diff --git a/crates/ui-egui/tests/redact.rs b/crates/ui-egui/tests/redact.rs index b2037788f3..8351b5974b 100644 --- a/crates/ui-egui/tests/redact.rs +++ b/crates/ui-egui/tests/redact.rs @@ -9,6 +9,7 @@ use pdfcraft_ui_egui::{Dialog, PdfCraftApp}; fn harness() -> Harness<'static, PdfCraftApp> { 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("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); app.set_option("zoom", "150").unwrap(); app diff --git a/crates/ui-egui/tests/signing.rs b/crates/ui-egui/tests/signing.rs index 22f82c8deb..a777517e63 100644 --- a/crates/ui-egui/tests/signing.rs +++ b/crates/ui-egui/tests/signing.rs @@ -30,6 +30,7 @@ fn dir() -> std::path::PathBuf { fn harness(dir: std::path::PathBuf) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("contract.pdf", None, FIXTURE.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); app.export_dir_override = Some(dir.to_string_lossy().into_owned()); @@ -155,6 +156,7 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { // Trusted certificates and the ID list persist. let saved = s.persist(); let mut fresh = PdfCraftApp::new(); + fresh.set_option("language", "en").unwrap(); fresh.restore(&saved); assert_eq!((fresh.digital_ids.len(), fresh.session.trusted_certificates().len()), (1, 1)); } @@ -248,6 +250,7 @@ fn clicking_an_empty_signature_field_signs_it() { #[test] fn os_store_identities_are_not_persisted() { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); for path in ["windows:Store signer", "keychain:Store signer", "file-id.p12"] { app.digital_ids.push(pdfcraft_ui_egui::DigitalIdEntry { path: path.into(), @@ -262,6 +265,7 @@ fn os_store_identities_are_not_persisted() { assert_eq!(settings["digital_ids"].as_array().unwrap().len(), 1); assert_eq!(settings["digital_ids"][0]["path"], "file-id.p12"); let mut restored = PdfCraftApp::new(); + restored.set_option("language", "en").unwrap(); restored.restore(&saved); assert_eq!(restored.digital_ids.len(), 1); assert_eq!(restored.digital_ids[0].path, "file-id.p12"); diff --git a/crates/ui-egui/tests/stamps.rs b/crates/ui-egui/tests/stamps.rs index 3665f1875c..ddb24e6a1c 100644 --- a/crates/ui-egui/tests/stamps.rs +++ b/crates/ui-egui/tests/stamps.rs @@ -8,6 +8,7 @@ use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; fn choosing_and_placing_stamps() { 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("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); app.set_option("zoom", "150").unwrap(); app @@ -47,6 +48,7 @@ fn choosing_and_placing_stamps() { fn creating_placing_and_keeping_custom_stamps() { 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("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); app.set_option("zoom", "150").unwrap(); app @@ -88,6 +90,7 @@ fn creating_placing_and_keeping_custom_stamps() { // The library is kept with the app's settings. let saved = s.persist(); let mut again = PdfCraftApp::new(); + again.set_option("language", "en").unwrap(); again.restore(&saved); assert_eq!(again.custom_stamps.len(), 1); assert_eq!((again.custom_stamps[0].category.as_str(), again.custom_stamps[0].name.as_str()), ("Company", "logo")); diff --git a/crates/ui-egui/tests/text_select.rs b/crates/ui-egui/tests/text_select.rs index c2beef929b..683f391c9e 100644 --- a/crates/ui-egui/tests/text_select.rs +++ b/crates/ui-egui/tests/text_select.rs @@ -29,6 +29,7 @@ trailer << /Root 1 0 R >> fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> (Harness<'static, PdfCraftApp>, Pos2) { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("lines.pdf", None, two_lines()).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("author", "Tester").unwrap(); diff --git a/crates/ui-egui/tests/theme.rs b/crates/ui-egui/tests/theme.rs index 9a90181f3d..2b16796f4f 100644 --- a/crates/ui-egui/tests/theme.rs +++ b/crates/ui-egui/tests/theme.rs @@ -86,13 +86,16 @@ fn toolbar_and_view_menu_share_three_state_preferences() { fn saved_preferences_restore_with_legacy_light_dark_settings() { for (value, preference) in [("light", ThemePreference::Light), ("dark", ThemePreference::Dark), ("system", ThemePreference::System)] { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.set_option("theme", value).unwrap(); let saved = app.persist(); let mut restored = PdfCraftApp::new(); + restored.set_option("language", "en").unwrap(); restored.restore(&saved); assert_eq!(restored.theme_preference, preference); } let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.restore(r#"{"theme":"Dark"}"#); assert_eq!(app.theme_preference, ThemePreference::Dark); assert_eq!(app.theme, ThemeKind::Dark); @@ -107,6 +110,7 @@ fn saved_preferences_restore_with_legacy_light_dark_settings() { #[test] fn commands_and_options_apply_before_the_first_frame() { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.run_command("view.theme.dark"); assert_eq!(app.theme_preference, ThemePreference::Dark); assert_eq!(app.theme, ThemeKind::Dark); diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 5299c66488..ecc7a21af0 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -20,6 +20,7 @@ trailer << /Root 1 0 R >> fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); setup(&mut app); app }); @@ -75,6 +76,7 @@ fn a_tab_with_an_arabic_file_name_keeps_its_logical_accessible_name() { #[test] fn garbage_input_is_rejected_without_panicking() { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); assert!(app.open_bytes("junk.pdf", None, b"this is not a pdf".to_vec()).is_err()); assert!(app.open_bytes("empty.pdf", None, Vec::new()).is_err()); let mut truncated = FIXTURE.to_vec(); @@ -173,9 +175,11 @@ fn drag_selects_text_and_copy_returns_it() { #[test] fn persistence_round_trips_and_tolerates_garbage() { let mut a = PdfCraftApp::new(); + a.set_option("language", "en").unwrap(); a.set_option("theme", "dark").unwrap(); let json = a.persist(); let mut b = PdfCraftApp::new(); + b.set_option("language", "en").unwrap(); b.restore(&json); assert_eq!(b.theme, pdfcraft_ui_egui::theme::ThemeKind::Dark); b.restore("{not json"); @@ -246,7 +250,11 @@ impl egui::DroppedFile for Dropped { #[test] fn dropping_a_pdf_on_the_window_opens_it() { - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| PdfCraftApp::new()); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); h.run_steps(3); assert!(h.state().views.is_empty()); let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: "dropped.pdf".into(), bytes: FIXTURE.to_vec() }); @@ -258,7 +266,11 @@ fn dropping_a_pdf_on_the_window_opens_it() { #[test] fn pdfs_dropped_on_the_combine_tab_join_its_list() { - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| PdfCraftApp::new()); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); h.run_steps(3); h.state_mut().execute("page.combine"); h.run_steps(2); @@ -284,7 +296,11 @@ fn a_folder_dropped_on_the_combine_tab_adds_its_pdfs() { std::fs::create_dir_all(dir.join("inner")).unwrap(); std::fs::write(dir.join("one.pdf"), FIXTURE).unwrap(); std::fs::write(dir.join("inner").join("two.pdf"), FIXTURE).unwrap(); - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| PdfCraftApp::new()); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); h.run_steps(3); h.state_mut().execute("page.combine"); h.run_steps(2); @@ -321,9 +337,11 @@ fn default_workspace_mode_persists_and_tolerates_invalid_settings() { use pdfcraft_ui_egui::Mode; for mode in [Mode::AllTools, Mode::Read, Mode::Edit, Mode::Convert, Mode::Sign] { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.default_mode = mode; app.set_option("mode", "sign").unwrap(); let mut restored = PdfCraftApp::new(); + restored.set_option("language", "en").unwrap(); restored.restore(&app.persist()); assert_eq!(restored.default_mode, mode); restored.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).unwrap(); @@ -331,6 +349,7 @@ fn default_workspace_mode_persists_and_tolerates_invalid_settings() { } for json in ["{}", "{not json", r#"{"default_mode": null}"#, r#"{"default_mode": 5}"#, r#"{"default_mode": "unknown"}"#] { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.restore(json); assert_eq!(app.default_mode, Mode::AllTools); app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).unwrap(); @@ -342,6 +361,7 @@ fn default_workspace_mode_persists_and_tolerates_invalid_settings() { fn newly_opened_pdfs_use_the_default_workspace() { use pdfcraft_ui_egui::{LeftPanel, Mode}; let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.restore(r#"{"default_mode": "edit"}"#); app.open_bytes("first.pdf", None, FIXTURE.to_vec()).unwrap(); assert_eq!(app.mode, Mode::Edit); @@ -365,6 +385,7 @@ fn explicit_mode_overrides_default_before_and_after_open() { for before in [false, true] { for (value, mode) in [("all", Mode::AllTools), ("read", Mode::Read), ("edit", Mode::Edit), ("convert", Mode::Convert), ("sign", Mode::Sign)] { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.default_mode = Mode::Read; if before { app.set_option("mode", value).unwrap(); @@ -403,6 +424,7 @@ fn preferences_selects_default_workspace_for_next_open() { fn explicit_mode_preserves_independent_tool_and_panel_options() { use pdfcraft_ui_egui::LeftPanel; let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.set_option("tool", "export").unwrap(); app.set_option("left", "closed").unwrap(); app.set_option("mode", "edit").unwrap(); @@ -418,6 +440,7 @@ fn opening_a_pdf_keeps_a_closed_left_panel_and_the_chosen_tool() { use pdfcraft_ui_egui::{LeftPanel, Mode}; // `--left closed` without `--mode`, default workspace Edit: the panel stays closed. let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.default_mode = Mode::Edit; app.set_option("left", "closed").unwrap(); app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).unwrap(); @@ -425,6 +448,7 @@ fn opening_a_pdf_keeps_a_closed_left_panel_and_the_chosen_tool() { assert!(!app.left_open); // Default All Tools: opening another PDF leaves the tool panel the user picked. let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.set_option("tool", "export").unwrap(); app.open_bytes("first.pdf", None, FIXTURE.to_vec()).unwrap(); app.open_bytes("second.pdf", None, FIXTURE.to_vec()).unwrap(); diff --git a/crates/ui-egui/tests/updates.rs b/crates/ui-egui/tests/updates.rs index 141c27818f..c06bd8b2c3 100644 --- a/crates/ui-egui/tests/updates.rs +++ b/crates/ui-egui/tests/updates.rs @@ -15,6 +15,7 @@ fn source(answer: Result<&str, &str>) -> UpdateSource { fn harness(answer: Result<&str, &str>) -> Harness<'static, PdfCraftApp> { Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.update_source = Some(source(answer)); app }) @@ -78,6 +79,7 @@ fn nothing_is_asked_until_the_user_checks() { let counted = calls.clone(); let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); // Settings from a build that had the startup option are ignored. app.restore(r#"{"check_updates_at_start": true}"#); let counted = counted.clone(); diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index aedeff8281..2d8881eabd 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -35,6 +35,7 @@ fn harness(options: &'static [(&'static str, &'static str)]) -> Harness<'static, fn harness_stepping(step_dt: f32, options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(step_dt).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("pages.pdf", None, PAGES.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); @@ -63,6 +64,7 @@ trailer << /Root 1 0 R >> fn mixed_harness(options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("mixed.pdf", None, MIXED.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); @@ -230,6 +232,7 @@ fn layout_option_rejects_typos() { #[test] fn view_options_without_a_document() { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); assert!(app.set_option("default-layout", "single").is_ok(), "the default needs no document"); assert_eq!(app.view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::Single); assert!(app.set_option("cover", "on").is_err(), "cover needs an open document"); @@ -251,6 +254,7 @@ fn default_page_display_is_used_for_new_documents() { // Persisted preferences survive a restart. let saved = h.state().persist(); let mut fresh = PdfCraftApp::new(); + fresh.set_option("language", "en").unwrap(); fresh.restore(&saved); assert_eq!(fresh.view_defaults.layout, PageLayout::Single); // Garbage keeps the previous default; casing is forgiven; legacy files lack the key. @@ -259,6 +263,7 @@ fn default_page_display_is_used_for_new_documents() { fresh.restore(r#"{"default_layout":"TWO-UP"}"#); assert_eq!(fresh.view_defaults.layout, PageLayout::TwoUp); let mut legacy = PdfCraftApp::new(); + legacy.set_option("language", "en").unwrap(); legacy.restore("{}"); assert_eq!(legacy.view_defaults.layout, PageLayout::Continuous); } @@ -287,6 +292,7 @@ fn default_zoom_is_used_for_new_documents() { // Saved settings keep it, and garbage leaves it alone. let saved = h.state().persist(); let mut fresh = PdfCraftApp::new(); + fresh.set_option("language", "en").unwrap(); fresh.restore(&saved); fresh.restore(r#"{"default_zoom":"bogus"}"#); fresh.open_bytes("again.pdf", None, PAGES.to_vec()).expect("opens"); @@ -463,6 +469,7 @@ trailer << /Root 1 0 R >> fn form_harness() -> Harness<'static, PdfCraftApp> { 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("form.pdf", None, FORM.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); app @@ -531,6 +538,7 @@ fn required_radio_buttons_get_a_round_red_border() { let _gpu = gpu(); 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("radios.pdf", None, RADIOS.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); @@ -575,6 +583,7 @@ fn field_highlighting_is_remembered() { assert!(h.state().views[1].highlight_fields, "the next document opens highlighted"); let saved = h.state().persist(); let mut next = PdfCraftApp::new(); + next.set_option("language", "en").unwrap(); next.restore(&saved); next.open_bytes("form.pdf", None, FORM.to_vec()).expect("opens"); assert!(next.views[0].highlight_fields, "and so does the next session"); @@ -666,6 +675,7 @@ trailer << /Root 1 0 R >> let bytes = pdf.clone(); let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).with_pixels_per_point(ppp).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("lines.pdf", None, bytes.clone()).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 39802d6f33..67dcecb2d4 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -34,6 +34,7 @@ fn fixture(n: usize) -> Vec { fn harness() -> Harness<'static, PdfCraftApp> { 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("a.pdf", None, fixture(5)).unwrap(); app.open_bytes("b.pdf", None, fixture(2)).unwrap(); app @@ -213,6 +214,7 @@ trailer << /Root 1 0 R >> .to_vec(); let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("damaged.pdf", None, damaged.clone()).unwrap(); app }); @@ -250,6 +252,7 @@ fn initial_view_is_edited_and_honoured_on_open() { // Opening the saved file follows it. let bytes = s.session.save_bytes(id).unwrap(); let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("again.pdf", None, bytes.to_vec()).unwrap(); let view = &app.views[0]; assert_eq!((view.current, view.cover, app.right), (1, true, Some(pdfcraft_ui_egui::RightPanel::Bookmarks))); @@ -559,6 +562,7 @@ fn page_down_steps_every_spread_when_several_fit_on_screen() { let bytes = sized_fixture(&sizes); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("book.pdf", None, bytes).unwrap(); app }); diff --git a/crates/ui-egui/tests/xfa.rs b/crates/ui-egui/tests/xfa.rs index f03d12c46a..587972d570 100644 --- a/crates/ui-egui/tests/xfa.rs +++ b/crates/ui-egui/tests/xfa.rs @@ -36,6 +36,7 @@ const XFA_PACKET: &str = "<< /Length 52 >>\nstream\n) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); app.open_bytes("form.pdf", None, bytes).expect("opens"); app }); From 7d9cc53472b703729e8ab7cd8bc35a93ba78aa2f Mon Sep 17 00:00:00 2001 From: Hal Goff Date: Fri, 9 Oct 2026 14:14:27 +0100 Subject: [PATCH 07/23] Issue #225: a closed Comments panel stays closed when picking comment tools (#501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking any comment tool opened the Comments panel whenever no right panel was open, so closing it was undone by the next tool switch. Now closing Comments (its close button or the rail icon) is remembered, across restarts, and comment tools leave it closed until the user opens it again (rail, View menu, Comments ▸ list, or a Reply action). Switching to or closing another panel doesn't count. Opening a document with comments still shows them, as Acrobat does. Claude-Session: https://claude.ai/code/session_01VNJqS2dEQawHJn1qTRJiKp Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 6 ++++- crates/ui-egui/src/chrome.rs | 4 ++-- crates/ui-egui/src/commands.rs | 6 ++--- crates/ui-egui/src/lib.rs | 19 +++++++++++++++ crates/ui-egui/src/panels.rs | 2 +- crates/ui-egui/tests/comments.rs | 40 ++++++++++++++++++++++++++++++++ 6 files changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4030d7c6e4..065d01fe21 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2001,7 +2001,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let field_props = field_props.then(|| view.prepare.selected.clone()).flatten(); match canvas_action { Some(comments::CanvasAction::Edit(e)) => view.pending_edit = Some(*e), - Some(comments::CanvasAction::OpenComments) => app.right = Some(RightPanel::Comments), + // `choose_right_panel` would borrow all of `app` while `view` is held; same effect. + Some(comments::CanvasAction::OpenComments) => { + app.right = Some(RightPanel::Comments); + app.comments_panel_closed = false; + } Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)), None => {} } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 41df36e7c1..bc4a717a42 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -240,7 +240,7 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { (RightPanel::Compare, tl!("Compare")), ] { if ui.radio(app.right == Some(p), tl!(label)).clicked() { - app.right = Some(p); + app.choose_right_panel(Some(p)); } } }); @@ -278,7 +278,7 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.painter().circle_filled(c, 3.0, t.accent); } if r.clicked() { - app.right = if selected { None } else { Some(panel) }; + app.choose_right_panel(if selected { None } else { Some(panel) }); } }; rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 6d3f97f9bd..126b7f319c 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -180,13 +180,13 @@ impl PdfCraftApp { "view.theme.system" => self.set_theme_preference(ThemePreference::System), "view.theme.light" => self.set_theme_preference(ThemePreference::Light), "view.theme.dark" => self.set_theme_preference(ThemePreference::Dark), - "comment.list" => self.right = Some(RightPanel::Comments), + "comment.list" => self.choose_right_panel(Some(RightPanel::Comments)), tool if crate::comments::CommentTool::from_command(tool).is_some() => { let Some(tool) = crate::comments::CommentTool::from_command(tool) else { return false }; self.comment_prefs.group_tool[tool.group()] = tool; self.quick_tool = crate::QuickTool::Comment(tool); - // Acrobat opens the Comments panel with the commenting tools. - if self.right.is_none() { + // Acrobat opens the Comments panel with the commenting tools, unless the user closed it. + if self.right.is_none() && !self.comments_panel_closed { self.right = Some(RightPanel::Comments); } // A text selection made before picking a markup tool is marked right away. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index db6137b4e0..91f2edd884 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -356,6 +356,9 @@ pub struct PdfCraftApp { pub left: LeftPanel, pub left_open: bool, pub right: Option, + /// The user closed the Comments panel, so picking a comment tool leaves it closed until they + /// open it again (#225). Remembered across restarts. + pub comments_panel_closed: bool, pub quick_tool: QuickTool, /// The tool to go back to when Space, held for a temporary Hand, is released. space_hand: Option, @@ -602,6 +605,7 @@ impl PdfCraftApp { left: LeftPanel::AllTools, left_open: true, right: None, + comments_panel_closed: false, quick_tool: QuickTool::Select, space_hand: None, comment_prefs: Default::default(), @@ -1165,6 +1169,17 @@ impl PdfCraftApp { }; } + /// Opens a right panel, or closes it with `None`, because the user chose to. Closing Comments + /// keeps comment tools from reopening it; opening it again lets them (#225). + pub fn choose_right_panel(&mut self, panel: Option) { + if panel == Some(RightPanel::Comments) { + self.comments_panel_closed = false; + } else if panel.is_none() && self.right == Some(RightPanel::Comments) { + self.comments_panel_closed = true; + } + self.right = panel; + } + /// Serialize the user's persistent state (recent files, theme). Local only. pub fn persist(&self) -> String { let trusted: Vec = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect(); @@ -1190,6 +1205,7 @@ impl PdfCraftApp { "javascript": self.session.javascript(), "actions": actions_ui::encode(&self.custom_actions), "combine_columns": self.combine_columns.to_json(), + "comments_panel_closed": self.comments_panel_closed, }) .to_string() } @@ -1217,6 +1233,9 @@ impl PdfCraftApp { if let Some(on) = v["highlight_fields"].as_bool() { self.view_defaults.highlight_fields = on; } + if let Some(closed) = v["comments_panel_closed"].as_bool() { + self.comments_panel_closed = closed; + } if let Some(defaults) = v["default_zoom"].as_str().and_then(|zoom| self.view_defaults.with_zoom(zoom)) { self.view_defaults = defaults; } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 5475947703..27ac404e8e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -584,7 +584,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } if close { - app.right = None; + app.choose_right_panel(None); } if let Some(e) = panel_edit { app.apply_edit(e); diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 688a962ede..e596e74fcf 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -600,3 +600,43 @@ fn the_opacity_set_for_a_tool_goes_into_its_new_comments() { h.run_steps(2); h.get_by_label("Opacity"); } + +/// #225: comment tools open the Comments panel until the user closes it; then it stays closed, +/// across restarts, until they open it again. +#[test] +fn a_closed_comments_panel_stays_closed_when_picking_comment_tools() { + use pdfcraft_ui_egui::RightPanel; + let mut h = harness(|_| {}); + assert_eq!(h.state().right, None); + assert!(h.state_mut().execute("comment.highlight")); + h.run_steps(2); + assert_eq!(h.state().right, Some(RightPanel::Comments), "Acrobat opens Comments with the tools"); + h.get_by_label("Close").click(); + h.run_steps(2); + assert_eq!(h.state().right, None); + for tool in ["select", "comment.underline", "comment.note", "comment.highlight"] { + if tool == "select" { + h.state_mut().quick_tool = QuickTool::Select; + } else { + assert!(h.state_mut().execute(tool)); + } + h.run_steps(2); + assert_eq!(h.state().right, None, "{tool} reopened the closed Comments panel"); + } + let mut again = PdfCraftApp::new(); + again.restore(&h.state().persist()); + assert!(again.comments_panel_closed, "the choice survives a restart"); + // Opening it again from the rail lets the tools open it once more. + h.get_by_label("Comments").click(); + h.run_steps(2); + assert_eq!(h.state().right, Some(RightPanel::Comments)); + assert!(!h.state().comments_panel_closed); + // What the rail and View menu call; "Bookmarks" labels two controls on screen. + h.state_mut().choose_right_panel(Some(RightPanel::Bookmarks)); + h.state_mut().choose_right_panel(None); + h.run_steps(2); + assert!(!h.state().comments_panel_closed, "switching to and closing another panel isn't closing Comments"); + assert!(h.state_mut().execute("comment.strikeout")); + h.run_steps(2); + assert_eq!(h.state().right, Some(RightPanel::Comments)); +} From bc1d67cb615ac0672b498fbdc74047db65336f2e Mon Sep 17 00:00:00 2001 From: Erige005 <162100015+Erige005@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:14:34 +0700 Subject: [PATCH 08/23] Export to Word: detect tables of at most 63 columns, Word's limit (#479) (#483) --- crates/export/src/lib.rs | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index e92d09ec16..ba165b09c8 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -96,10 +96,11 @@ fn level(b: &Block, body: f64) -> u8 { /// How far two cell left edges may drift (points) and still be the same grid column. const COL_TOL: f64 = 4.0; -/// More grid columns than this isn't a table. It also bounds a table's size: every row is -/// materialized to all its columns, so a hostile page laid out as a staircase of blocks would -/// otherwise make (blocks / 2)² cells. -const MAX_COLS: usize = 64; +/// More grid columns than this isn't a table. It is Word's limit: a .docx whose table has 64 or +/// more columns doesn't open. It also bounds a table's size: every row is materialized to all +/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make +/// (blocks / 2)² cells. +const MAX_COLS: usize = 63; /// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. fn is_cell_like(b: &Block) -> bool { (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() @@ -809,6 +810,28 @@ mod tests { assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); } + #[test] + fn word_opens_tables_up_to_its_column_limit() { + // Rows of short numbers on a regular grid, n columns wide. + let grid = |n: usize| Page { + width: 1500.0, + height: 792.0, + blocks: (0..3) + .flat_map(|row| { + (0..n).map(move |col| { + let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0); + Block { text: (col + 1).to_string(), rect: [x, y, x + 8.0, y + 8.0], size: 8.0, bold: false, italic: false } + }) + }) + .collect(), + images: Vec::new(), + }; + let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches(" Date: Fri, 9 Oct 2026 15:14:40 +0200 Subject: [PATCH 09/23] Comments: a new highlight opens its note for typing (#475) (#481) Adding a highlight selected its card in the Comments panel but left the note closed; the text field only appeared after double-clicking the card. Acrobat opens the note for typing as soon as the highlight is made. apply_edit now opens the inline editor for a new highlight (text drag, selection then tool, area highlight all pass through it), unless a note typed into another card is still unsaved, so that text is never dropped. --- crates/ui-egui/src/editing.rs | 8 ++++++++ crates/ui-egui/tests/comments.rs | 23 +++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index a28a5485c9..e8b9aad57b 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -57,6 +57,14 @@ impl PdfCraftApp { let newest = info.annotations.iter().filter(|x| x.page == a.page && x.in_reply_to.is_none()).map(|x| x.index).max(); view.comments.selected = newest.map(|n| (a.page, n)); view.comments.reveal = true; + // A new highlight opens its note for typing straight away, as in Acrobat, + // unless a note typed into another card is still unsaved. + if let (pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, .. }, Some(n)) = (&a.shape, newest) + && a.contents.is_empty() + && view.comments.editing.as_ref().is_none_or(|(_, _, text)| text.is_empty()) + { + view.comments.editing = Some((a.page, n, String::new())); + } } Edit::DeleteAnnotation { .. } => view.comments.selected = None, _ => {} diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index e596e74fcf..cb1dc9a3f2 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -116,6 +116,29 @@ fn dragging_over_text_with_the_highlighter_highlights_it() { assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Add highlight")); } +#[test] +fn a_new_highlight_opens_its_note_for_typing() { + let mut h = harness(|app| app.set_option("quick", "highlight").unwrap()); + assert_eq!(h.state().views[0].comments.editing, None); + let (a, b) = { + let v = &h.state().views[0]; + (v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 14).expect("glyph")) + }; + drag(&mut h, a, b); + assert_eq!(comments(&h).len(), 1); + assert_eq!(h.state().views[0].comments.editing, Some((0, 0, String::new())), "the Comments panel edits the new highlight"); + // An area highlight opens its note too, replacing the empty one. + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + assert_eq!(comments(&h).len(), 2); + assert_eq!(h.state().views[0].comments.editing, Some((0, 1, String::new()))); + // A note being typed is kept: the next highlight is only selected. + h.state_mut().views[0].comments.editing = Some((0, 1, "typed".into())); + drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0)); + assert_eq!(comments(&h).len(), 3); + assert_eq!(h.state().views[0].comments.selected, Some((0, 2))); + assert_eq!(h.state().views[0].comments.editing, Some((0, 1, "typed".into()))); +} + #[test] fn a_selection_made_first_is_marked_when_a_tool_is_picked() { let mut h = harness(|_| {}); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 647ddbf180..6cc6eaef0f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3494,8 +3494,8 @@ milestone = "M5" status = "shipped" commands = ["comment.highlight"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/engine/src/tests.rs::highlight_multiplies_over_text"] -notes = "drag over text, or pick the tool with text selected; agents can highlight by text search" +evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/ui-egui/tests/comments.rs::a_new_highlight_opens_its_note_for_typing", "crates/engine/src/tests.rs::highlight_multiplies_over_text"] +notes = "drag over text, or pick the tool with text selected; a new highlight opens its note for typing in the Comments panel; agents can highlight by text search" [[feature]] id = "comment.underline" From 5f570eab01032729c0959b9cf3f3524dd5d94aa5 Mon Sep 17 00:00:00 2001 From: Chen Tao Date: Fri, 9 Oct 2026 22:14:46 +0900 Subject: [PATCH 10/23] M2.6: non-embedded Mincho fonts draw half-width katakana (#484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #368, a non-embedded Adobe-Japan1 font with a Mincho name (HeiseiMin-W3, MS-Mincho, ...) is drawn in document_japanese_font(), i.e. Shippori Mincho. Shippori Mincho has none of the 63 half-width katakana (U+FF61-U+FF9F), which are common in Japanese documents. hayro maps the CID through Adobe-Japan1-UCS2 to Unicode, finds no glyph in the face's cmap, and falls back to drawing the CID as a glyph index: half-width katakana came out as unrelated glyphs (ligatures such as fi and fl). The Mincho substitute is now BIZ UDMincho Regular, which covers half-width katakana (and was designed as a metric-compatible replacement for MS Mincho), falling back to document_japanese_font() when it isn't built in. The face used for Japanese text written into PDFs is unchanged. Without craft-fonts nothing changes. Tests: raster::tests::mincho_substitute_covers_half_width_katakana (with CRAFT_FONTS_DIR: the Mincho substitute's cmap maps U+FF61-U+FF9F and 日本; it lacked all 63 half-width katakana before; skips without craft-fonts). skrifa 0.44 is a dev-dependency of pdfcraft-render for it, the version pdfcraft-fonts already uses. The parity entry view.render-cid-fonts cites the test. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + contributors/people.toml | 5 +++++ crates/render/Cargo.toml | 4 ++++ crates/render/src/raster.rs | 31 ++++++++++++++++++++++++++++++- parity/acrobat-features.toml | 4 ++-- 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 10531cb1fd..7ce59c30a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3751,6 +3751,7 @@ dependencies = [ "log", "lopdf", "pdfcraft-fonts", + "skrifa 0.44.0", "thiserror 2.0.21", ] diff --git a/contributors/people.toml b/contributors/people.toml index 00beb65874..ade46dac11 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -59,3 +59,8 @@ sync_github_name = true [people.amitabhthakur] sync_github_name = true + +[people.chent11] +real_name = "Tao Chen" +display_name = "Tao Chen" +sync_github_name = false diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 2a5b974d44..fa68a06413 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -20,3 +20,7 @@ log = { workspace = true } # lopdf (bootstrap) draws random bytes for encryption; on the web they come from the browser. [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { version = "0.4", features = ["wasm_js"] } + +[dev-dependencies] +# Checks the Japanese substitute's character coverage; the version pdfcraft-fonts already uses. +skrifa = "0.44" diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 267be471d6..e6b6e7ea21 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -73,7 +73,13 @@ fn japanese_fallback(query: &FontQuery) -> Option<(FontData, u32)> { return None; } let face = match japanese_face(f.post_script_name.as_deref().unwrap_or_default(), f.is_serif, f.is_bold || f.font_weight >= 600) { - JapaneseFace::Mincho => pdfcraft_fonts::document_japanese_font(), + // BIZ UDMincho before the document face (Shippori Mincho): it covers half-width katakana + // (U+FF61–U+FF9F), which Shippori Mincho lacks, and hayro draws a CID the substitute + // can't map by Unicode by glyph index, i.e. as an unrelated glyph (fi, fl, …). + JapaneseFace::Mincho => pdfcraft_fonts::ui_japanese_fonts() + .into_iter() + .find(|c| c.family == "BIZ UDMincho" && c.style == "Regular") + .or_else(pdfcraft_fonts::document_japanese_font), JapaneseFace::Gothic { bold } => { let faces = pdfcraft_fonts::ui_japanese_fonts(); let style = if bold { "Bold" } else { "Regular" }; @@ -1652,6 +1658,29 @@ trailer << /Root 1 0 R >> } } + /// Half-width katakana are common in Japanese documents set in a non-embedded Mincho font + /// such as HeiseiMin-W3. The Mincho substitute must have those glyphs: one that lacks them + /// (Shippori Mincho) draws each CID by glyph index instead, as unrelated glyphs (fi, fl). + #[test] + fn mincho_substitute_covers_half_width_katakana() { + use hayro::hayro_interpret::font::FallbackFontQuery; + use hayro::hayro_interpret::hayro_cmap::CharacterCollection; + use skrifa::MetadataProvider; + let query = FontQuery::Fallback(FallbackFontQuery { + post_script_name: Some("HeiseiMin-W3".into()), + character_collection: Some(CharacterCollection { family: CidFamily::AdobeJapan1, supplement: 2 }), + ..FallbackFontQuery::default() + }); + let Some((data, index)) = super::japanese_fallback(&query) else { + eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese face to check"); + return; + }; + let font = skrifa::FontRef::from_index((*data).as_ref(), index).expect("a craft-fonts face parses"); + let charmap = font.charmap(); + let missing: Vec = ('\u{FF61}'..='\u{FF9F}').chain(['日', '本']).filter(|&c| charmap.map(c).is_none()).collect(); + assert!(missing.is_empty(), "the Mincho substitute lacks {missing:?}"); + } + #[test] fn japanese_font_names_pick_mincho_or_gothic() { use super::JapaneseFace::{Gothic, Mincho}; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6cc6eaef0f..627fe94bf3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -746,8 +746,8 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face"] -notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face)" +evidence = ["crates/render/src/raster.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster.rs::mincho_substitute_covers_half_width_katakana"] +notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face)" [[feature]] id = "view.render-image-codecs" From 5c5f4d8b49bf3e3cc24c4108ad434f53d2b26434 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:14:55 +0200 Subject: [PATCH 11/23] Phone photos follow EXIF orientation; image signatures stay upright on rotated pages (#434) (#485) * Stamps and added images follow a JPEG's EXIF orientation (part of #434) Phones store a portrait photo as landscape pixels plus an EXIF Orientation tag. A JPEG went into the PDF as it was (DCTDecode), and PDF image XObjects have no such tag, so a photo used as a custom stamp, an added or replaced image, a watermark or background, or a button image showed up turned 90 degrees. Before these images are embedded, a JPEG whose Orientation is not 1 is decoded, turned the way the tag says and re-encoded (quality 92). Its JFIF resolution is kept, swapped for quarter turns, so the natural size on the page stays right. Other images, JPEGs without the tag, JPEGs that fail to decode and photos over 64 megapixels are embedded unchanged, as before. Fill & Sign image signatures already applied the tag. * Image signatures stay upright and anchored on rotated pages (part of #434) On a page with /Rotate 90, 180 or 270 an image signature turned with the page, because its picture was drawn along user-space axes while the click or the "at" argument is a displayed point. It was also anchored at the wrong point: its left edge and vertical centre were taken in user space, so on a 90 degree page the image centre ended up elsewhere. The signature's appearance now carries the counter-rotation as its form /Matrix, which viewers fit to /Rect, so the picture reads upright as displayed. The page's rotation is kept in /PCPictureRotate, so restyling regenerates the same matrix and moving and resizing keep it. The box of the signature runs along the displayed axes, so its left edge and vertical centre are where they were asked for. The page view matrix in pdfcraft-model is split out as view_matrix_for. The Fill & Sign preview and the live move/resize preview draw the picture the way the appearance does, and the resize handles keep the image's proportions as displayed. comment_image_preview reports image_rotation, how far the image is shown turned (0 for one placed upright). Custom image stamps are left to #473. * Use is_multiple_of for the quarter-turn checks (clippy) (#434) * Probe the pointer preview away from egui_kittest's drawn cursor (#434) The kittest render paints a mouse cursor (a 16 px triangle with a black outline) at the hover point, so the 2 pt probe beside the pointer landed on its outline when zoom and view rotation made 2 pt only a pixel or two. Put the stroke's block at the picture's right end and probe there and at its far margin instead. --- Cargo.lock | 1 + crates/annot/Cargo.toml | 1 + crates/annot/README.md | 2 +- crates/annot/src/appearance.rs | 23 ++++- crates/annot/src/lib.rs | 18 ++++ crates/automation/README.md | 2 +- crates/automation/src/comments.rs | 3 +- crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 51 ++++++++++ crates/engine/src/lib.rs | 20 +++- crates/engine/src/signature_image.rs | 58 ++++++++--- crates/engine/src/upright_image.rs | 133 ++++++++++++++++++++++++++ crates/model/README.md | 2 + crates/model/src/lib.rs | 22 +++-- crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/canvas.rs | 21 ++-- crates/ui-egui/src/comments.rs | 8 +- crates/ui-egui/src/fill_sign.rs | 15 +-- crates/ui-egui/src/signature_drag.rs | 17 +++- crates/ui-egui/tests/fill_sign.rs | 97 ++++++++++++++----- 20 files changed, 419 insertions(+), 79 deletions(-) create mode 100644 crates/engine/src/upright_image.rs diff --git a/Cargo.lock b/Cargo.lock index 7ce59c30a7..ca64fd795e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3477,6 +3477,7 @@ dependencies = [ "hayro-syntax", "pdfcraft-cos", "pdfcraft-fonts", + "pdfcraft-model", "thiserror 2.0.21", ] diff --git a/crates/annot/Cargo.toml b/crates/annot/Cargo.toml index 9603e57224..8a31381493 100644 --- a/crates/annot/Cargo.toml +++ b/crates/annot/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } pdfcraft-fonts = { workspace = true } +pdfcraft-model = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/crates/annot/README.md b/crates/annot/README.md index c98cb6d2e8..38ad68b571 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -1,6 +1,6 @@ # pdfcraft-annot -Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends only on `pdfcraft-cos`. +Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, `pdfcraft-fonts` and `pdfcraft-model`. ## What it does diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 6045743f23..11b2143480 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -200,6 +200,12 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream { Stream::flate(d, content) } +/// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written +/// when the signature is added to a turned page): 0, 90, 180 or 270. +pub(crate) fn picture_turn(d: &Dict) -> i64 { + d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90 +} + /// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported. pub fn build(d: &Dict) -> Option { // Imported measurement appearances may include leaders, captions and formatting that @@ -444,11 +450,14 @@ pub fn build(d: &Dict) -> Option { return Some(form(rect, c.as_bytes(), res)); } // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills - // the rectangle. + // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets + // the standard counter-rotation as its form `/Matrix`: viewers fit the turned + // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { let [x0, y0, x1, y1] = rect; let (w, h) = (x1 - x0, y1 - y0); - let place = if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) { + let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); + let place = if image { format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) } else { let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; @@ -458,7 +467,15 @@ pub fn build(d: &Dict) -> Option { let mut xo = Dict::new(); xo.set(b"Pic".to_vec(), Object::Ref(pic)); res.set(b"XObject".to_vec(), Object::Dict(xo)); - return Some(form(rect, c.as_bytes(), res)); + let mut stream = form(rect, c.as_bytes(), res); + let turn = picture_turn(d); + if image && turn != 0 { + // The page's view matrix without its offset: display axes in user space. + let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); + let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; + stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); + } + return Some(stream); } // Only PdfCraft's own Fill & Sign marks are drawn here. let name = d.name(b"Name")?; diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 2abfdce553..18174c58d3 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -677,6 +677,16 @@ pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result Result { + let p = page_ref(doc, page)?; + let list = annots(doc, p); + let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn)) +} + // ── building ──────────────────────────────────────────────────────────────────────────────── /// Annotation flags (§12.5.3). @@ -968,6 +978,14 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1])); + // An image signature's rectangle is in user space; on a turned page its appearance is + // turned back (see `appearance::build`), so it reads upright as displayed. + if *image && matches!(clean.as_str(), "Signature" | "Initials") { + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if turn != 0 { + d.set(b"PCPictureRotate".to_vec(), Object::Int(turn)); + } + } } Shape::Ink { strokes } | Shape::Signature { strokes } => { d.set(b"C".to_vec(), rgb(style.color)); diff --git a/crates/automation/README.md b/crates/automation/README.md index 21dbc3f5ff..29e9eb9e79 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -23,7 +23,7 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS - Pages and positions are **1-based**. Rectangles are PDF points with the origin at the top-left of the displayed page. - Unknown arguments are rejected, so typos fail loudly. - Tools that change a document return its summary (`doc`, `pages`, `dirty`, `undo`, `redo`, …). -- `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document. +- `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation, `image_rotation` (how far clockwise the image is shown turned: 0 for one placed by PdfCraft, which keeps it upright as displayed) and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document. - For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle. - `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. - With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (` is outside the allowed directory `), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder. diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 4d97e3213b..094bf4984f 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -109,6 +109,7 @@ impl Automation { }; Ok(vec![ Content::Json(json!({ "page": page + 1, "index": index + 1, "rect": rect_to_view(info, annotation.rect), "rotation": info.rotation, + "image_rotation": (i64::from(info.rotation) - preview.turn).rem_euclid(360), "layer": layer, "opacity": preview.opacity, "dpi": dpi })), image, ]) @@ -386,7 +387,7 @@ impl Automation { let path = self.resolve(path, false)?; let file = std::fs::File::open(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; let image = pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?; - let edit = image.edit(page, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?; + let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?; return self.apply(a, edit); } let size = 10.0; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 83d5a411dd..e088a64f1d 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -642,7 +642,7 @@ pub fn tools() -> Vec { })), &["doc"], )), - t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") + t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") .ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])), t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), t( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 093a34431d..c287d7c4fc 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1485,6 +1485,7 @@ fn image_signature_preview_layers_are_read_only_and_survive_encrypted_save() { assert_eq!(meta["layer"], "background"); assert_eq!(image_meta["layer"], "image"); assert_eq!(meta["rotation"], 90); + assert_eq!(meta["image_rotation"], 0, "placed upright as displayed on the turned page"); assert_eq!(meta["opacity"], 0.5); assert_eq!((*width, *height), (120, 40)); assert_eq!(image::load_from_memory(background).unwrap().to_rgba8(), expected, "page text and the other signature remain"); @@ -2040,6 +2041,56 @@ fn note_icons_anchor_at_the_requested_corner_on_rotated_pages() { } } +/// An image signature is drawn upright as displayed, and anchored at the displayed point asked +/// for, on every `/Rotate`: its picture is placed in user space, which the page turns, so the +/// appearance is counter-rotated and its box is found in user space. +#[test] +fn image_signatures_stay_upright_on_rotated_pages() { + let dir = workdir("image-upright"); + // 80 x 40 px, one colour per quadrant: red and green above, blue and yellow below. + let quadrants = image::RgbaImage::from_fn(80, 40, |x, y| match (x < 40, y < 20) { + (true, true) => image::Rgba([255, 0, 0, 255]), + (false, true) => image::Rgba([0, 255, 0, 255]), + (true, false) => image::Rgba([0, 0, 255, 255]), + (false, false) => image::Rgba([255, 255, 0, 255]), + }); + quadrants.save(dir.join("quadrants.png")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap(); + for (page, degrees) in [(2, 90), (3, 180), (4, 270)] { + ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees })); + } + for page in 1..=4 { + // 64 x 32 pt from x = 60 as displayed, centred on y = 140. + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "signature", "at": [60, 140], "path": "quadrants.png" })); + } + let (white, red, green, blue, yellow) = ([255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]); + let check = |a: &mut Automation, doc: u64| { + for page in 1..=4 { + let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap(); + let Content::Png { data, .. } = &output[0] else { panic!() }; + let pixels = image::load_from_memory(data).unwrap().to_rgba8(); + for (x, y, want, what) in [ + (76, 132, red, "signature top-left"), + (108, 132, green, "signature top-right"), + (76, 148, blue, "signature bottom-left"), + (108, 148, yellow, "signature bottom-right"), + (56, 140, white, "left of the signature"), + (128, 140, white, "right of the signature"), + (92, 120, white, "above the signature"), + (92, 160, white, "below the signature"), + ] { + let got = pixels.get_pixel(x, y).0; + assert!(got[..3].iter().zip(want).all(|(g, w)| g.abs_diff(w) < 12), "page {page}, {what} at ({x}, {y}): {got:?}, want {want:?}"); + } + } + }; + check(&mut a, doc); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "placed.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "placed.pdf" }))["doc"].as_u64().unwrap(); + check(&mut a, reopened); +} + #[test] fn comment_checkmarks_locks_hiding_and_summaries_through_tools() { let dir = workdir("comment-polish"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2dca9559fa..22dd3d4cbb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,6 +22,7 @@ pub mod links; pub mod ocr; pub mod optimizer; pub mod signature_image; +mod upright_image; pub mod xfa; pub use signature_image::{ImageSignaturePreview, SignatureImage}; @@ -1505,7 +1506,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> ran.map_err(EditError::Invalid)?; } Edit::SetFieldImage { name, image } => { - let (img, _) = pdfcraft_create::image_xobject(doc, name, image)?; + let (img, _) = embed_image(doc, name, image)?; let px = match &*doc.get(img) { 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), @@ -1562,7 +1563,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> ImageEdit::Flip { horizontal } => { pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.rect, 0, *horizontal, !*horizontal)) } - ImageEdit::Replace { name, bytes } => pdfcraft_edit::ImageChange::Replace(pdfcraft_create::image_xobject(doc, name, bytes)?.0), + ImageEdit::Replace { name, bytes } => pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0), ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, }; pdfcraft_edit::change_image(doc, *page, *index, &c)?; @@ -1602,7 +1603,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; } Edit::AddImage { page, rect, name, bytes } => { - let (image, natural) = pdfcraft_create::image_xobject(doc, name, bytes)?; + let (image, natural) = embed_image(doc, name, bytes)?; let rect = match rect { Some(r) => *r, None => { @@ -1622,7 +1623,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); }; - let (image, _) = pdfcraft_create::image_xobject(doc, name, bytes)?; + let (image, _) = embed_image(doc, name, bytes)?; pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; } Edit::ApplyRedactions { pages } => { @@ -2977,6 +2978,15 @@ pub struct MarkFile { pub page: usize, } +/// Embed an image that is placed on a page, turned the way its EXIF Orientation says. +fn embed_image( + doc: &mut pdfcraft_cos::Document, + name: &str, + bytes: &[u8], +) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { + pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes)) +} + /// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { let head = &f.bytes[..f.bytes.len().min(1024)]; @@ -2985,7 +2995,7 @@ fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result>, config: pdfcraft_render::RenderConfig, @@ -56,10 +59,11 @@ impl Document { let image = SignatureImage::from_bytes(&bytes).map_err(|e| e.to_string())?; let opacity = pdfcraft_annot::props(&editor.cos, page, index).map(|p| p.opacity).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0) as f32; + let turn = pdfcraft_annot::picture_rotation(&editor.cos, page, index).map_err(|e| e.to_string())?; let mut cos = editor.cos.clone(); pdfcraft_annot::delete_annotation(&mut cos, page, index).map_err(|e| e.to_string())?; let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); - Ok(Some(ImageSignaturePreview { image, opacity, page, bytes, config: self.config.clone() })) + Ok(Some(ImageSignaturePreview { image, opacity, turn, page, bytes, config: self.config.clone() })) })? } } @@ -141,19 +145,29 @@ impl SignatureImage { self.size } - /// Left edge at `at`, vertically centered, with the same 150 pt width cap as typed names. - pub fn rect(&self, at: [f64; 2], initials: bool) -> Option<[f64; 4]> { + /// The user-space box for the image with its left edge at `at` (a user-space point on `page`), + /// vertically centered, with the same 150 pt width cap as typed names. Left and centered are + /// as displayed: on a page with `/Rotate` the box runs along the turned page's axes. + pub fn rect(&self, page: &PageInfo, at: [f64; 2], initials: bool) -> Option<[f64; 4]> { if !at.iter().all(|v| v.is_finite()) { return None; } let [w, h] = self.size; let scale = (150.0 / w as f64).min(if initials { 24.0 } else { 32.0 } / h as f64); let (width, height) = (w as f64 * scale, h as f64 * scale); - Some([at[0], at[1] - height / 2.0, at[0] + width, at[1] + height / 2.0]) + // Right and up as displayed, as user-space vectors: the page's view matrix without its offset. + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(i64::from(page.rotation), [0.0; 4]); + let (right, up) = ([a * width, b * width], [c * height / 2.0, d * height / 2.0]); + Some([ + at[0] + right[0].min(0.0) - up[0].abs(), + at[1] + right[1].min(0.0) - up[1].abs(), + at[0] + right[0].max(0.0) + up[0].abs(), + at[1] + right[1].max(0.0) + up[1].abs(), + ]) } - pub fn edit(&self, page: usize, at: [f64; 2], initials: bool, author: &str) -> Option { - let rect = self.rect(at, initials)?; + pub fn edit(&self, page: usize, info: &PageInfo, at: [f64; 2], initials: bool, author: &str) -> Option { + let rect = self.rect(info, at, initials)?; let label = if initials { "Add initials" } else { "Add signature" }; Some(Edit::Batch { label: label.into(), @@ -172,6 +186,12 @@ impl SignatureImage { mod tests { use super::*; + /// A 300 x 400 page shown turned `rotation` degrees clockwise. + fn page(rotation: u16) -> PageInfo { + let (width, height) = if rotation.is_multiple_of(180) { (300.0, 400.0) } else { (400.0, 300.0) }; + PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation } + } + fn png(w: u32, h: u32) -> Vec { let image = image::RgbaImage::from_pixel(w, h, image::Rgba([40, 60, 80, 128])); let mut out = Cursor::new(Vec::new()); @@ -184,10 +204,10 @@ mod tests { let image = SignatureImage::from_bytes(&png(400, 40)).unwrap(); assert_eq!(image.size(), [400, 40]); assert_eq!(&image.rgba()[..4], &[40, 60, 80, 128]); - let Edit::Batch { edits, .. } = image.edit(0, [20.0, 100.0], false, "Ada").unwrap() else { panic!() }; + let Edit::Batch { edits, .. } = image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap() else { panic!() }; let Edit::AddCustomStamp { rect, .. } = &edits[0] else { panic!() }; assert_eq!(*rect, [20.0, 92.5, 170.0, 107.5]); - assert!(image.edit(0, [f64::NAN, 0.0], false, "").is_none()); + assert!(image.edit(0, &page(0), [f64::NAN, 0.0], false, "").is_none()); assert_eq!(SignatureImage::from_bytes(image.bytes()).unwrap(), image); } @@ -236,7 +256,7 @@ mod tests { let mut session = crate::Session::new(); let blank = session.create_blank(300.0, 400.0, 1).unwrap(); let id = session.open_new("form.pdf", blank).unwrap(); - session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap(); + session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap(); let a = &session.get(id).unwrap().info.annotations[0]; assert_eq!(a.rect, [20.0, 99.75, 170.0, 100.25]); } @@ -247,7 +267,7 @@ mod tests { let mut session = crate::Session::new(); let blank = session.create_blank(300.0, 400.0, 1).unwrap(); let id = session.open_new("form.pdf", blank).unwrap(); - session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap(); + session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap(); let doc = session.get(id).unwrap(); let bytes = doc.bytes.clone(); let generation = doc.edit_generation(); @@ -268,7 +288,7 @@ mod tests { let mut session = crate::Session::new(); let blank = session.create_blank(300.0, 400.0, 1).unwrap(); let id = session.open_new("form.pdf", blank).unwrap(); - session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap(); + session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap(); let doc = session.doc_mut(id).unwrap(); let cos = &mut doc.editor.as_mut().unwrap().cos; let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); @@ -278,4 +298,20 @@ mod tests { let error = doc.image_signature_preview(0, 0).err().unwrap(); assert!(error.contains("4 megapixels"), "{error}"); } + + /// "Left edge at the point, centered vertically" is as displayed: on a turned page the box + /// is another one in user space. The image is 64 x 32 pt; the displayed point is (60, 100). + #[test] + fn the_signature_box_is_laid_out_as_displayed_on_turned_pages() { + let image = SignatureImage::from_bytes(&png(80, 40)).unwrap(); + for (rotation, at, want) in [ + (0, [60.0, 300.0], [60.0, 284.0, 124.0, 316.0]), + (90, [100.0, 60.0], [84.0, 60.0, 116.0, 124.0]), + (180, [240.0, 100.0], [176.0, 84.0, 240.0, 116.0]), + (270, [200.0, 340.0], [184.0, 276.0, 216.0, 340.0]), + ] { + let rect = image.rect(&page(rotation), at, false).unwrap(); + assert!(rect.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{rotation}: {rect:?}, want {want:?}"); + } + } } diff --git a/crates/engine/src/upright_image.rs b/crates/engine/src/upright_image.rs new file mode 100644 index 0000000000..dc2b9cdbfc --- /dev/null +++ b/crates/engine/src/upright_image.rs @@ -0,0 +1,133 @@ +//! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have +//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page +//! (stamps, added images, replacements) go through here first. + +use std::borrow::Cow; +use std::io::Cursor; + +use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; +use image::metadata::Orientation; +use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader}; + +/// Quality of a re-encoded JPEG (the camera's own is rarely higher). +const QUALITY: u8 = 92; +/// Larger photos are embedded as they are: turning them would take hundreds of MB. +const MAX_PIXELS: u64 = 64 << 20; + +/// A JPEG whose EXIF Orientation is not "as stored" has its pixels turned (and re-encoded); +/// everything else, and any JPEG this can't read, is returned unchanged. +pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> { + match turned(bytes) { + Some(out) => Cow::Owned(out), + None => Cow::Borrowed(bytes), + } +} + +fn turned(bytes: &[u8]) -> Option> { + if !bytes.starts_with(&[0xFF, 0xD8]) { + return None; + } + let mut decoder = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_decoder().ok()?; + let orientation = decoder.orientation().ok()?; + let (w, h) = decoder.dimensions(); + if orientation == Orientation::NoTransforms || u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; + } + let mut image = DynamicImage::from_decoder(decoder).ok()?; + image.apply_orientation(orientation); + let mut out = Vec::new(); + { + let mut encoder = JpegEncoder::new_with_quality(&mut out, QUALITY); + // Keep the resolution, which sets the image's natural size on the page. + if let Some(mut density) = jfif_density(bytes) { + if matches!(orientation, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH) { + density.density = (density.density.1, density.density.0); + } + encoder.set_pixel_density(density); + } + match &image { + DynamicImage::ImageLuma8(gray) => encoder.encode_image(gray).ok()?, + _ => encoder.encode_image(&image.to_rgb8()).ok()?, + } + } + Some(out) +} + +/// The resolution in a JFIF header, if the file has one in inches or centimetres. +fn jfif_density(bytes: &[u8]) -> Option { + let mut i = 2usize; + while let Some(&[0xFF, marker, hi, lo]) = bytes.get(i..i.saturating_add(4)) { + let len = usize::from(u16::from_be_bytes([hi, lo])); + let seg = bytes.get(i + 4..i + 2 + len)?; + if marker == 0xE0 && seg.starts_with(b"JFIF\0") { + let &[unit, x0, x1, y0, y1] = seg.get(7..12)? else { return None }; + let unit = match unit { + 1 => PixelDensityUnit::Inches, + 2 => PixelDensityUnit::Centimeters, + _ => return None, + }; + return Some(PixelDensity { density: (u16::from_be_bytes([x0, x1]), u16::from_be_bytes([y0, y1])), unit }); + } + if marker == 0xDA { + return None; + } + i += 2 + len; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Edit, MarkFile}; + + /// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 + /// degrees clockwise). + fn phone_jpeg(orientation: u8, dpi: u16) -> Vec { + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches }); + encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]); + let mut out = jpeg[..2].to_vec(); + out.extend_from_slice(&[0xff, 0xe1]); + out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&exif); + out.extend_from_slice(&jpeg[2..]); + out + } + + #[test] + fn orientation_is_applied_and_plain_images_are_left_alone() { + let photo = phone_jpeg(6, 144); + let turned = upright(&photo); + assert!(matches!(turned, Cow::Owned(_))); + let decoded = image::load_from_memory(&turned).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (20, 60)); + assert_eq!(jfif_density(&turned), Some(PixelDensity { density: (72, 144), unit: PixelDensityUnit::Inches })); + let stored = phone_jpeg(1, 72); + assert!(matches!(upright(&stored), Cow::Borrowed(_))); + assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_))); + assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_))); + } + + #[test] + fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { + let mut session = crate::Session::new(); + let blank = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open_new("form.pdf", blank).unwrap(); + // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. + // Named "Signature" so `signature_image` finds its picture. + let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 }; + let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; + session.apply(id, stamp).unwrap(); + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); + let obj = cos.get(picture); + let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() }; + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60))); + let rect = pdfcraft_annot::summaries(cos)[0].rect; + assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}"); + } +} diff --git a/crates/model/README.md b/crates/model/README.md index 90d6cebad3..19b457b61d 100644 --- a/crates/model/README.md +++ b/crates/model/README.md @@ -8,6 +8,8 @@ L3/L4 crates need: - `Page::crop`, `Page::rotation` and `Page::view_matrix`: the displayed page's geometry, and the matrix from "display space" (origin at the bottom-left of the page as shown, after `/Rotate`) to user space, so content can be placed the way the reader sees the page. + `view_matrix_for(rotation, rect)` is the same matrix for any rectangle, e.g. to draw a + comment's picture upright inside its user-space `/Rect` on a turned page. `organize`, `annot` and `forms` still carry their own small walkers; they move here as the model grows (ADR-0004). diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 6e558dea53..ff9e70c003 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -49,13 +49,21 @@ impl Page { /// The matrix `[a b c d e f]` from display space (origin at the bottom-left of the page as /// shown, y up, after `/Rotate`) to user space. pub fn view_matrix(&self, doc: &Document) -> [f64; 6] { - let [x0, y0, x1, y1] = self.crop(doc); - match self.rotation(doc) { - 90 => [0.0, 1.0, -1.0, 0.0, x1, y0], - 180 => [-1.0, 0.0, 0.0, -1.0, x1, y1], - 270 => [0.0, -1.0, 1.0, 0.0, x0, y1], - _ => [1.0, 0.0, 0.0, 1.0, x0, y0], - } + view_matrix_for(self.rotation(doc), self.crop(doc)) + } +} + +/// [`Page::view_matrix`] for a region `crop` (`[x0, y0, x1, y1]`) shown turned clockwise by +/// `rotation` (0, 90, 180 or 270): the matrix from the turned region's own display space +/// (origin at its bottom-left, y up) to the region's space. For a rectangle in user space it +/// puts an upright picture into that rectangle as the page displays it. +pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] { + let [x0, y0, x1, y1] = crop; + match rotation { + 90 => [0.0, 1.0, -1.0, 0.0, x1, y0], + 180 => [-1.0, 0.0, 0.0, -1.0, x1, y1], + 270 => [0.0, -1.0, 1.0, 0.0, x0, y1], + _ => [1.0, 0.0, 0.0, 1.0, x0, y0], } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f255d70e30..ed1e9a3276 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -12,6 +12,7 @@ workspace = true pdfcraft-fonts = { workspace = true } pdfcraft-engine = { workspace = true } pdfcraft-render = { workspace = true } +pdfcraft-model = { workspace = true } pdfcraft-platform = { workspace = true } egui = { workspace = true } egui_extras = { workspace = true } @@ -57,7 +58,6 @@ web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlE [dev-dependencies] pdfcraft-xfa = { workspace = true } pdfcraft-cos = { workspace = true } -pdfcraft-model = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } image = { version = "0.25", default-features = false, features = ["png"] } # Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 065d01fe21..beb88cc873 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -991,21 +991,28 @@ pub struct PageXform { } impl PageXform { - /// Draw an image in PDF user space, preserving orientation through both page rotations. + /// Draw an image in the PDF user-space `rect` as a custom stamp's appearance draws it: turned + /// back by `turn` (the page /Rotate it was placed for), so with the page's own rotation it + /// reads upright as displayed. pub(crate) fn paint_user_image( &self, painter: &egui::Painter, tex: egui::TextureId, - info: &DocInfo, - page: usize, + p: &pdfcraft_render::PageInfo, rect: [f64; 4], + turn: i64, color: Color32, ) { - let Some(p) = info.pages.get(page) else { return }; let mut mesh = egui::Mesh::with_texture(tex); - for (x, y, u, v) in [(rect[0], rect[3], 0.0, 0.0), (rect[2], rect[3], 1.0, 0.0), (rect[2], rect[1], 1.0, 1.0), (rect[0], rect[1], 0.0, 1.0)] { - let p = p.user_to_view(x as f32, y as f32); - mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(p[0] / self.pw, p[1] / self.ph), uv: pos2(u, v), color }); + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let (shown_w, shown_h) = if turn % 180 == 0 { (w, h) } else { (h, w) }; + let [a, b, c, d, e, f] = pdfcraft_model::view_matrix_for(turn, rect); + for (u, v) in [(0.0_f32, 0.0_f32), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] { + // The texture's (u, v), from its top-left, in the picture's upright frame, then in user space. + let (dx, dy) = (f64::from(u) * shown_w, f64::from(1.0 - v) * shown_h); + let (x, y) = (a * dx + c * dy + e, b * dx + d * dy + f); + let q = p.user_to_view(x as f32, y as f32); + mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(q[0] / self.pw, q[1] / self.ph), uv: pos2(u, v), color }); } mesh.add_triangle(0, 1, 2); mesh.add_triangle(0, 2, 3); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 37438d05d3..3d08ac95f1 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -808,11 +808,9 @@ fn select_input( && let Some(o) = origin { if let (Some(h), Some(a)) = (handle_at(o), selected) { - let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).and_then(|ratio| { - let p = cx.info.pages.get(cx.page)?; - let rotation = u32::from(p.rotation) + u32::from(cx.xf.rot); - Some(if rotation % 180 == 0 { ratio } else { ratio.recip() }) - }); + // On screen, the image is also turned by the view's rotation. + let turned = !cx.xf.rot.is_multiple_of(180); + let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).map(|ratio| if turned { ratio.recip() } else { ratio }); cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: h, from: o, aspect_ratio }); consumed = true; } else if let Some(a) = cx.hit(o) diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index ecd4eb6f83..eb6204264f 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -5,7 +5,7 @@ use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2}; use pdfcraft_engine::{Edit, FillMark, NewAnnotation, Shape, SignatureImage, Style}; -use pdfcraft_render::DocInfo; +use pdfcraft_render::{DocInfo, PageInfo}; use crate::canvas::{DocView, PageXform}; use crate::theme::Tokens; @@ -212,10 +212,10 @@ pub fn typed_signature_at(page: usize, at: [f64; 2], text: &str, height: f64, au } /// Place a saved signature or initials. -pub fn place(page: usize, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option { +pub fn place(page: usize, info: &PageInfo, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option { match sig { SavedSig::Drawn(strokes) => signature_at(page, at, strokes, author), - SavedSig::Image(image) => image.edit(page, at, initials, author), + SavedSig::Image(image) => image.edit(page, info, at, initials, author), SavedSig::Typed(text) => { let [left, bottom, right, top] = pdfcraft_engine::script_outline(text).bounds(); let height = if initials { 24.0_f64 } else { 32.0_f64 }; @@ -373,6 +373,7 @@ pub(crate) fn page_input( if !resp.contains_pointer() || !xf.rect.contains(pointer) { return None; } + let p = info.pages.get(page)?; let at = to_user(xf, info, page, pointer); let saved = match tool { FillTool::Signature => signature, @@ -380,9 +381,9 @@ pub(crate) fn page_input( _ => None, }; if let Some(SavedSig::Image(image)) = saved { - if let Some(rect) = image.rect(at, tool == FillTool::Initials) { + if let Some(rect) = image.rect(p, at, tool == FillTool::Initials) { let tex = image_texture(ui, image, preview); - xf.paint_user_image(ui.painter(), tex, info, page, rect, Color32::WHITE); + xf.paint_user_image(ui.painter(), tex, p, rect, i64::from(p.rotation), Color32::WHITE); ui.ctx().set_cursor_icon(egui::CursorIcon::None); } } else { @@ -401,11 +402,11 @@ pub(crate) fn page_input( Some(FillAction::Edit(Box::new(typed(page, [at[0], at[1] + TEXT_SIZE * 0.6], &format!("{m}/{d}/{y}"), author)))) } FillTool::Signature => match signature { - Some(s) => place(page, at, s, false, author).map(|e| FillAction::Signature(Box::new(e))), + Some(s) => place(page, p, at, s, false, author).map(|e| FillAction::Signature(Box::new(e))), None => Some(FillAction::CreateSignature), }, FillTool::Initials => match initials { - Some(s) => place(page, at, s, true, author).map(|e| FillAction::Signature(Box::new(e))), + Some(s) => place(page, p, at, s, true, author).map(|e| FillAction::Signature(Box::new(e))), None => Some(FillAction::CreateInitials), }, mark => { diff --git a/crates/ui-egui/src/signature_drag.rs b/crates/ui-egui/src/signature_drag.rs index e566ba3c39..ad97d1b0b0 100644 --- a/crates/ui-egui/src/signature_drag.rs +++ b/crates/ui-egui/src/signature_drag.rs @@ -17,6 +17,8 @@ pub(crate) struct SignatureDrag { struct Layers { image: TextureHandle, opacity: f32, + /// The page /Rotate the PDF draws the image turned back by. + turn: i64, renderer: RenderPool, background: Option, tag: Option, @@ -37,9 +39,13 @@ impl SignatureDrag { && let Ok(Some(preview)) = doc.image_signature_preview(page, index) { let [width, height] = preview.image.size(); - self.aspect_ratio = Some(width as f32 / height as f32); + let ratio = width as f32 / height as f32; + // As displayed, the image is turned by the page's rotation less the turn it is drawn back by. + let shown = doc.info.pages.get(page).map_or(0, |p| i64::from(p.rotation)) - preview.turn; + self.aspect_ratio = Some(if shown.rem_euclid(180) == 0 { ratio } else { ratio.recip() }); self.layers = Some(Layers { opacity: preview.opacity, + turn: preview.turn, image: ctx.load_texture( "signature-drag-image", ColorImage::from_rgba_unmultiplied(preview.image.size(), preview.image.rgba()), @@ -87,7 +93,8 @@ impl SignatureDrag { self.key.is_some_and(|(_, p, i)| (p, i) == (page, index)) && self.layers.is_some() } - /// Use the embedded image's proportions, even after an edge handle stretched its rectangle. + /// Use the embedded image's proportions, even after an edge handle stretched its rectangle: its + /// width over its height as the page is displayed (before the view's own rotation). pub fn aspect_ratio(&self, page: usize, index: usize) -> Option { self.key.filter(|(_, p, i)| (*p, *i) == (page, index)).and(self.aspect_ratio) } @@ -124,7 +131,7 @@ impl SignatureDrag { if !(dragging || released || layers.settling) { return; } - let (Some(background), Some(a)) = (&layers.background, cx.get(index)) else { return }; + let (Some(background), Some(a), Some(p)) = (&layers.background, cx.get(index), cx.info.pages.get(page)) else { return }; let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), painter.ctx().input(|i| i.pointer.hover_pos()), pending); let rect = cx.rect_to_user(rect); let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect)); @@ -132,9 +139,9 @@ impl SignatureDrag { cx.xf.paint_user_image( &painter, layers.image.id(), - cx.info, - page, + p, rect, + layers.turn, egui::Color32::from_white_alpha((layers.opacity * 255.0).round() as u8), ); } diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs index 7888555e5e..2e85ff7659 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -45,6 +45,21 @@ fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 { xf.norm_to_screen(p[0] / xf.pw, p[1] / xf.ph) } +/// The screen point `dx` points right and `dy` points up, as displayed, from the user-space point +/// (`x`, `y`): unlike `at`, offsets don't turn with the page's /Rotate. +fn shown(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32, dx: f32, dy: f32) -> Pos2 { + let s = h.state(); + let page = &s.session.get(s.views[0].id).unwrap().info.pages[0]; + let xf = pdfcraft_ui_egui::canvas::PageXform { + rect: s.views[0].page_screen_rect(0).expect("on screen"), + rot: s.views[0].rotation, + pw: page.width, + ph: page.height, + }; + let p = page.user_to_view(x, y); + xf.norm_to_screen((p[0] + dx) / xf.pw, (p[1] - dy) / xf.ph) +} + fn click(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) { let p = at(h, x, y); h.hover_at(p); @@ -297,7 +312,8 @@ fn saved_signature_cards_remove_and_add_without_changing_the_document() { fn long_typed_names_fit_the_placed_signature_and_keep_every_outline() { let text = "Alexandria Catherine Elizabeth Montgomery-Wellington"; let sig = SavedSig::Typed(text.into()); - let pdfcraft_engine::Edit::AddAnnotation(a) = pdfcraft_ui_egui::fill_sign::place(0, [40.0, 200.0], &sig, false, "").unwrap() else { + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let pdfcraft_engine::Edit::AddAnnotation(a) = pdfcraft_ui_egui::fill_sign::place(0, &page, [40.0, 200.0], &sig, false, "").unwrap() else { panic!("expected annotation"); }; let pdfcraft_engine::Shape::TypedSignature { rect, contours } = a.shape else { panic!("expected typed signature") }; @@ -319,9 +335,15 @@ fn long_typed_names_fit_the_placed_signature_and_keep_every_outline() { } fn signature_file(test: &str) -> std::path::PathBuf { + signature_file_with_mark(test, 10..20) +} + +/// A 120 x 40 signature: a stroke across the middle and a block above it at the columns `mark` +/// (the rest is transparent), so a turned or flipped picture shows. +fn signature_file_with_mark(test: &str, mark: std::ops::Range) -> std::path::PathBuf { let path = std::env::temp_dir().join(format!("pdfcraft-signature-{test}-{}.png", std::process::id())); let image = image::RgbaImage::from_fn(120, 40, |x, y| { - if (10..110).contains(&x) && (15..25).contains(&y) || (10..20).contains(&x) && (5..15).contains(&y) { + if (10..110).contains(&x) && (15..25).contains(&y) || mark.contains(&x) && (5..15).contains(&y) { image::Rgba([20, 30, 40, 255]) } else { image::Rgba([0, 0, 0, 0]) @@ -404,7 +426,8 @@ fn image_signatures_and_initials_can_be_imported_placed_and_remembered() { #[test] fn image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation() { - let path = signature_file("pointer"); + // The block above the stroke is at its right end, away from the pointer's own cursor. + let path = signature_file_with_mark("pointer", 100..110); let image = pdfcraft_engine::SignatureImage::read(std::fs::File::open(&path).unwrap()).unwrap(); let mut h = harness(); // A link under the placement point must not replace the image with a hand cursor. @@ -416,7 +439,8 @@ fn image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation() }); h.state_mut().signature = Some(SavedSig::Image(image)); h.state_mut().execute("sign.fill.signature"); - // Exercise document rotation and view rotation separately and together. + // Exercise document rotation and view rotation separately and together. The image is upright + // as displayed, whatever the page's /Rotate; the view rotation turns the whole screen. let mut previous_document_rotation = 0; for (document_rotation, view_rotation, zoom) in [(0, 0, "150"), (90, 0, "100"), (0, 90, "100"), (90, 90, "75"), (0, 180, "75"), (0, 270, "100")] { let delta = document_rotation - previous_document_rotation; @@ -433,20 +457,23 @@ fn image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation() h.hover_at(at(&h, 40.0, y)); h.run_steps(2); assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the image replaces the crosshair"); - let ink = at(&h, 90.0, y); - let margin = at(&h, 42.0, y); - let upper_stroke = at(&h, 52.0, y + 8.0); - let lower_margin = at(&h, 52.0, y - 8.0); + // The picture is 96 x 32 pt with its left edge at the pointer: the stroke spans 8..88 + // and the block above it 80..88. egui_kittest paints a mouse cursor, a 16 px triangle + // down and right of the pointer, over the render, so only probe far from the pointer. + let ink = shown(&h, 40.0, y, 50.0, 0.0); + let margin = shown(&h, 40.0, y, 92.0, 0.0); + let upper_stroke = shown(&h, 40.0, y, 84.0, 8.0); + let lower_margin = shown(&h, 40.0, y, 84.0, -8.0); let pixels = h.render().unwrap(); assert!(pixels.get_pixel(ink.x as u32, ink.y as u32).0[..3].iter().all(|v| *v < 60), "ink follows the pointer at the placed size"); assert!(pixels.get_pixel(margin.x as u32, margin.y as u32).0[..3].iter().all(|v| *v > 240), "alpha exposes the page"); assert!( pixels.get_pixel(upper_stroke.x as u32, upper_stroke.y as u32).0[..3].iter().all(|v| *v < 60), - "the asymmetric image rotates with the page" + "the asymmetric image stays upright as displayed" ); assert!(pixels.get_pixel(lower_margin.x as u32, lower_margin.y as u32).0[..3].iter().all(|v| *v > 240), "the image isn't flipped"); if y == 150.0 { - let old = at(&h, 90.0, 250.0); + let old = shown(&h, 40.0, 250.0, 50.0, 0.0); assert!(pixels.get_pixel(old.x as u32, old.y as u32).0[..3].iter().all(|v| *v > 240), "moving the pointer removes the old preview"); } if document_rotation == 0 @@ -665,31 +692,53 @@ fn embedded_image_signature_live_gestures_follow_document_and_view_rotation() { h.state_mut().signature = Some(SavedSig::Image(image.clone())); h.state_mut().execute("sign.fill.signature"); h.run_steps(3); - click(&mut h, 40.0, 250.0); - rendered_ink(&mut h, 90.0, 250.0); + // Points as displayed (from the shown page's bottom-left, y up). The signature is upright + // as displayed, so the same points apply whatever the page's /Rotate. + let page = h.state().session.get(h.state().views[0].id).unwrap().info.pages[0].clone(); + let u = |x: f32, y: f32| page.view_to_user(x, page.height - y); + let [x, y] = u(40.0, 250.0); + click(&mut h, x, y); + let [x, y] = u(90.0, 250.0); + rendered_ink(&mut h, x, y); // Future placements may use a different signature: drag the PDF's embedded image. h.state_mut().signature = None; - h.drag_at(at(&h, 136.0, 234.0)); + let [x, y] = u(136.0, 234.0); + h.drag_at(at(&h, x, y)); h.run_steps(1); - let end = at(&h, 184.0, 228.0); + let [x, y] = u(184.0, 228.0); + let end = at(&h, x, y); h.hover_at(end); - rendered_ink(&mut h, 160.0, 242.0); + let [x, y] = u(160.0, 242.0); + rendered_ink(&mut h, x, y); let pixels = h.render().unwrap(); - assert!(pixel_at(&h, &pixels, 58.0, 254.0)[..3].iter().all(|v| *v < 60), "asymmetric ink rotates during resize"); - assert!(pixel_at(&h, &pixels, 58.0, 230.0)[..3].iter().all(|v| *v > 240), "alpha isn't flipped"); + let [x, y] = u(58.0, 254.0); + assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v < 60), "asymmetric ink stays upright during resize"); + let [x, y] = u(58.0, 230.0); + assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v > 240), "alpha isn't flipped"); h.drop_at(end); h.run_steps(3); - h.drag_at(at(&h, 112.0, 242.0)); + // The corner keeps the image's 3:1 as displayed: 144 x 48 from the top-left corner. + let ([x0, y0], [x1, y1]) = (u(40.0, 218.0), u(184.0, 266.0)); + let rect = h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].rect; + let want = [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]; + assert!(rect.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{rect:?} != {want:?}"); + let [x, y] = u(112.0, 242.0); + h.drag_at(at(&h, x, y)); h.run_steps(1); - h.hover_at(at(&h, 132.0, 142.0)); + let [x, y] = u(132.0, 142.0); + let target = at(&h, x, y); + h.hover_at(target); h.run_steps(2); let pixels = h.render().unwrap(); - assert!(pixel_at(&h, &pixels, 132.0, 142.0)[..3].iter().all(|v| *v < 60), "image moves before release"); - assert!(pixel_at(&h, &pixels, 160.0, 242.0)[..3].iter().all(|v| *v > 240), "old ink is removed during movement"); - assert!(!has_blue_near(&pixels, at(&h, 204.0, 166.0))); - h.drop_at(at(&h, 132.0, 142.0)); + assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v < 60), "image moves before release"); + let [x, y] = u(160.0, 242.0); + assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v > 240), "old ink is removed during movement"); + let [x, y] = u(204.0, 166.0); + let corner = at(&h, x, y); + assert!(!has_blue_near(&pixels, corner)); + h.drop_at(target); h.run_steps(2); - assert!(has_blue_near(&h.render().unwrap(), at(&h, 204.0, 166.0))); + assert!(has_blue_near(&h.render().unwrap(), corner)); } std::fs::remove_file(path).unwrap(); } From 3c771e0395ca6d453a63847cf27aeef829119473 Mon Sep 17 00:00:00 2001 From: pauliusiv123-rgb <235694617+pauliusiv123-rgb@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:15:00 +0300 Subject: [PATCH 12/23] =?UTF-8?q?Issue=20#425:=20long=20file=20names=20and?= =?UTF-8?q?=20paths=20on=20Home=20end=20in=20"=E2=80=A6"=20before=20the=20?= =?UTF-8?q?row's=20details=20(#443)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/folders_ui.rs | 5 +++-- crates/ui-egui/src/home.rs | 10 ++++++---- crates/ui-egui/src/widgets.rs | 10 ++++++++++ crates/ui-egui/tests/folders.rs | 26 ++++++++++++++++++++++++++ crates/ui-egui/tests/ui.rs | 30 ++++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/folders_ui.rs b/crates/ui-egui/src/folders_ui.rs index f5cd90b5d6..727417e2fc 100644 --- a/crates/ui-egui/src/folders_ui.rs +++ b/crates/ui-egui/src/folders_ui.rs @@ -364,11 +364,12 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { 18.0, egui::Color32::from_rgb(0xE0, 0x3E, 0x3E), ); - ui.painter().text(rect.min + vec2(62.0, 19.0), Align2::LEFT_CENTER, crate::bidi::visual(&f.name), theme::medium(13.0), t.text); let size = crate::panels::human_size(usize::try_from(f.size).unwrap_or(usize::MAX)); let when = f.modified.map(crate::combine_ui::ago).unwrap_or_default(); let detail = if when.is_empty() { size } else { format!("{when} · {size}") }; - ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, detail, theme::regular(12.0), t.text_muted); + let detail = ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, detail, theme::regular(12.0), t.text_muted); + let width = detail.left() - 16.0 - (rect.left() + 62.0); + widgets::row_text(ui, rect.min + vec2(62.0, 19.0), crate::bidi::visual(&f.name), theme::medium(13.0), t.text, width); if resp.on_hover_text(&f.path).clicked() { open = Some(f.path.clone()); } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 7e21c126b8..35f4efb68c 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -123,16 +123,18 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { 22.0, egui::Color32::from_rgb(0xE0, 0x3E, 0x3E), ); - ui.painter().text(rect.min + vec2(46.0, 15.0), Align2::LEFT_CENTER, crate::bidi::visual(&r.name), theme::medium(13.5), t.text); - ui.painter().text(rect.min + vec2(46.0, 32.0), Align2::LEFT_CENTER, crate::bidi::visual(&r.path), theme::regular(11.0), t.text_faint); - ui.painter().text( + let detail = ui.painter().text( rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, format!("{} {} · {}", r.pages, tl!("pages"), human_size(r.size)), theme::regular(12.0), t.text_muted, ); - if resp.clicked() { + // The name and path stop short of the page count; hovering shows the whole path. + let width = detail.left() - 16.0 - (rect.left() + 46.0); + widgets::row_text(ui, rect.min + vec2(46.0, 15.0), crate::bidi::visual(&r.name), theme::medium(13.5), t.text, width); + widgets::row_text(ui, rect.min + vec2(46.0, 32.0), crate::bidi::visual(&r.path), theme::regular(11.0), t.text_faint, width); + if resp.on_hover_text(&r.path).clicked() { open = Some(r.path.clone()); } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index dc06ff1cad..8351c12659 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -81,6 +81,16 @@ pub fn section_title(ui: &mut egui::Ui, text: &str) { ui.add_space(2.0); } +/// Paint one line of `text` left-aligned and vertically centred on `pos`, cut with "…" so it is +/// at most `max_width` wide: a long file name or path stays inside its row (#425). +pub fn row_text(ui: &egui::Ui, pos: egui::Pos2, text: impl Into, font: egui::FontId, color: Color32, max_width: f32) { + let mut job = egui::text::LayoutJob::simple_singleline(text.into(), font, color); + job.wrap = egui::text::TextWrapping::truncate_at_width(max_width.max(0.0)); + let galley = ui.painter().layout_job(job); + let rect = Align2::LEFT_CENTER.anchor_size(pos, galley.size()); + ui.painter().galley(rect.min, galley, color); +} + /// Transient message at the bottom centre. pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) { let Some((msg, start)) = app.toast.clone() else { return }; diff --git a/crates/ui-egui/tests/folders.rs b/crates/ui-egui/tests/folders.rs index 0f9fe80bda..583cc35eaa 100644 --- a/crates/ui-egui/tests/folders.rs +++ b/crates/ui-egui/tests/folders.rs @@ -197,6 +197,32 @@ fn show_more_reveals_the_older_pdfs() { h.get_by_label("Show less"); } +#[test] +fn a_long_pdf_name_is_cut_before_its_date_and_size() { + let dir = TempFolder::new("long"); + // Short enough for Windows' 260-character paths under the temp folder. + let name = format!("{}.pdf", "Signed contract with a long name ".repeat(4)); + dir.file(&name, FIXTURE, Duration::from_secs(10)); + let folder = dir.path(); + let mut h = harness(move |app| { + app.pin_folder(&folder); + }); + h.run_steps(2); + let text: Vec<(String, bool, egui::Rect)> = h + .output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_owned(), t.galley.elided, t.visual_bounding_rect())), + _ => None, + }) + .collect(); + let (_, cut, rect) = text.iter().find(|(s, ..)| *s == name).expect("the PDF is listed"); + let detail = text.iter().find(|(s, _, r)| s.contains('·') && r.y_range().contains(rect.center().y)).expect("its row shows the date and size").2; + assert!(*cut, "the name is cut short"); + assert!(rect.right() < detail.left(), "the name ends before the date and size"); +} + #[test] fn pinning_is_limited_and_never_duplicates() { let mut app = PdfCraftApp::new(); diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index ecc7a21af0..2582ce88cf 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -37,6 +37,36 @@ fn home_shows_welcome_and_tools() { h.get_by_label("Open file"); } +/// 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)> { + h.output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_owned(), t.galley.elided, t.visual_bounding_rect())), + _ => None, + }) + .collect() +} + +#[test] +fn long_recent_names_and_paths_are_cut_before_the_page_count() { + let name = format!("{}.pdf", "Quarterly report final version ".repeat(10)); + let path = format!("/{}/{name}", "A folder with a long name".repeat(8)); + let h = harness({ + let (name, path) = (name.clone(), path.clone()); + move |app| app.recent.push(pdfcraft_ui_egui::RecentFile { name, path, pages: 7, size: 2048 }) + }); + let text = painted_text(&h); + let detail = text.iter().find(|(s, ..)| s.starts_with("7 pages")).expect("the row shows its page count").2; + for whole in [&name, &path] { + let (_, cut, rect) = text.iter().find(|(s, ..)| s == whole).expect("the row shows the name and the path"); + assert!(*cut, "{whole:?} is cut short"); + assert!(rect.right() < detail.left(), "{whole:?} ends before the page count"); + } +} + #[test] fn every_catalog_tool_is_listed_after_view_more() { let mut h = harness(|_| {}); From 561377eb1f4506e4c0f21dff370a8f045a08c1d1 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 Date: Fri, 9 Oct 2026 13:15:06 +0000 Subject: [PATCH 13/23] Keep document view state consistent when closing tabs and reverting (#439) Preserve the selected document when an earlier inactive tab closes. Clear the reverted document's form drafts and queued edit only after a successful Revert. Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Co-authored-by: Codex --- crates/ui-egui/src/editing.rs | 100 +++++++++++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/tests/forms.rs | 74 +++++++++++++++++++++++++ crates/ui-egui/tests/ui.rs | 91 +++++++++++++++++++++++++++++++ 4 files changed, 265 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index e8b9aad57b..1d1b690256 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -427,7 +427,13 @@ impl PdfCraftApp { if let Some(i) = self.active && let Some(d) = self.session.get(id) { - self.views[i].document_changed(&d.info); + let view = &mut self.views[i]; + view.document_changed(&d.info); + // Revert discards this source's uncommitted typing as well as model edits. + // Otherwise a later Save could write a rejected draft back into the file. + view.forms.focus = None; + view.forms.committed = None; + view.pending_edit = None; } self.notify_tr("Reverted to the last saved version"); } @@ -796,3 +802,95 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod revert_draft_tests { + use super::*; + use crate::forms_ui::Focus; + use pdfcraft_engine::FieldValue; + + fn focus(text: &str) -> Focus { + Focus { + name: "name".into(), + widget: 0, + text: text.into(), + picked: Vec::new(), + request_focus: false, + select_all: false, + calendar: None, + calendar_rect: None, + } + } + + fn app() -> PdfCraftApp { + let mut app = PdfCraftApp::new(); + app.open_bytes("source.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap(); + app + } + + fn queue_draft(app: &mut PdfCraftApp, index: usize, text: &str) { + app.views[index].forms.focus = Some(focus(text)); + let form = app.session.get(app.views[index].id).unwrap().form.clone(); + crate::forms_ui::commit(&mut app.views[index], &form); + assert!(app.views[index].forms.committed.is_some() && app.views[index].pending_edit.is_some()); + } + + #[test] + fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() { + for queued in [false, true] { + let mut app = app(); + let source = app.views[0].id; + assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Committed change".into()) })); + if queued { + queue_draft(&mut app, 0, "Queued source draft"); + } else { + app.views[0].forms.focus = Some(focus("Focused source draft")); + } + app.open_bytes("other.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap(); + let other = app.views[1].id; + assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Other committed value".into()) })); + queue_draft(&mut app, 1, "Other queued draft"); + app.views[1].forms.focus = Some(focus("Other focused draft")); + let other_bytes = app.session.get(other).unwrap().bytes.clone(); + let other_generation = app.session.get(other).unwrap().edit_generation(); + let source_generation = app.session.get(source).unwrap().edit_generation(); + app.active = Some(0); + app.revert_active(); + let reverted = app.session.get(source).unwrap(); + assert!(!reverted.dirty); + assert_eq!(reverted.edit_generation(), source_generation + 1); + assert!(reverted.form.iter().find(|field| field.name == "name").unwrap().value.is_empty()); + assert!(app.views[0].forms.focus.is_none() && app.views[0].forms.committed.is_none() && app.views[0].pending_edit.is_none()); + assert!(!app.has_unsaved_work(0)); + assert_eq!(app.views[1].forms.focus.as_ref().unwrap().text, "Other focused draft"); + assert_eq!(app.views[1].forms.committed.as_ref().unwrap().text, "Other queued draft"); + assert!( + matches!(&app.views[1].pending_edit, Some(Edit::SetFieldValue { name, value: FieldValue::Text(text) }) if name == "name" && text == "Other queued draft") + ); + let untouched = app.session.get(other).unwrap(); + assert_eq!(untouched.bytes, other_bytes); + assert_eq!(untouched.edit_generation(), other_generation); + assert!(untouched.dirty && app.has_unsaved_work(1)); + assert_eq!(app.active_ids(), Some((0, source))); + } + } + + #[test] + fn failed_revert_preserves_focused_and_queued_drafts() { + let mut app = app(); + let source = app.views[0].id; + queue_draft(&mut app, 0, "Queued draft"); + app.views[0].forms.focus = Some(focus("Focused draft")); + // Exercise the ordinary NoDocument error boundary without a corrupt fixture. + // This verifies UI failure preservation, not other engine refresh failures. + app.session.close(source); + app.revert_active(); + assert_eq!(app.views[0].forms.focus.as_ref().unwrap().text, "Focused draft"); + assert_eq!(app.views[0].forms.committed.as_ref().unwrap().text, "Queued draft"); + assert!( + matches!(&app.views[0].pending_edit, Some(Edit::SetFieldValue { name, value: FieldValue::Text(text) }) if name == "name" && text == "Queued draft") + ); + assert_eq!(app.active_ids(), Some((0, source))); + assert_eq!(app.toast.as_ref().unwrap().0, pdfcraft_engine::EditError::NoDocument.to_string()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 91f2edd884..3770168b0c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1036,6 +1036,7 @@ impl PdfCraftApp { self.session.close(id); self.active = match self.active { _ if self.views.is_empty() => None, + Some(a) if a > index => Some(a - 1), Some(a) if a >= self.views.len() => Some(self.views.len() - 1), other => other, }; diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index f4f3552dab..57a80dff17 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -401,3 +401,77 @@ fn flattening_while_typing_flattens_the_typed_text() { .unwrap_or_default(); assert!(text.contains("Flat Ada"), "into the page: {text:?}"); } + +#[cfg(not(target_arch = "wasm32"))] +mod revert_tests { + use super::*; + use egui_kittest::kittest::Queryable; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct RevertDir(PathBuf); + impl RevertDir { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + for _ in 0..128 { + let path = std::env::temp_dir().join(format!("pdfcraft-revert-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + match std::fs::create_dir(&path) { + Ok(()) => return Self(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("creating Revert test directory: {error}"), + } + } + panic!("no unused Revert test directory after 128 attempts"); + } + } + impl Drop for RevertDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn reverting_rejected_input_saves_the_original_field_and_appearance() { + let dir = RevertDir::new(); + let path = dir.0.join("saved.pdf"); + let mut h = harness(); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldValue { + name: "name".into(), + value: pdfcraft_engine::FieldValue::Text("Saved original".into()), + })); + h.state_mut().save_override = Some(path.to_string_lossy().into_owned()); + assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::InPlace)); + let saved = std::fs::read(&path).unwrap(); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "name".into(), + props: Box::new(pdfcraft_engine::FieldProps { + validate: Some(pdfcraft_engine::form_scripts::Validate::Range { min: Some(0.0), max: Some(10.0) }), + ..Default::default() + }), + })); + h.run_steps(3); + click_field(&mut h, "name", 0); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("99".into())); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); + h.run_steps(4); + assert_eq!(h.state().views[0].forms.focus.as_ref().unwrap().text, "99"); + assert_eq!(std::fs::read(&path).unwrap(), saved, "rejection leaves the saved file untouched"); + assert!(h.state_mut().execute("file.revert")); + h.run_steps(2); + h.get_all_by_label("Revert").last().expect("confirmation button").click(); + h.run_steps(4); + assert!(h.state().views[0].forms.focus.is_none() && h.state().views[0].pending_edit.is_none()); + assert_eq!(h.state().first_dirty(), None, "Revert discards rejected typing too"); + assert_eq!(value(&h, "name"), ["Saved original"]); + assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::InPlace)); + assert_eq!(saved_value(&path, "name"), ["Saved original"]); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(bytes, saved, "later Save cannot resurrect the discarded draft"); + let mut renderer = pdfcraft_render::PageRenderer::new(std::sync::Arc::new(bytes), Default::default()); + let output = renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, ..Default::default() }); + assert!(output.error.is_none(), "saved appearance is readable: {:?}", output.error); + assert!(output.text.unwrap().plain_text().contains("Saved original"), "saved AP still draws the original value"); + } +} diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 2582ce88cf..27fa3bc50f 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -485,3 +485,94 @@ fn opening_a_pdf_keeps_a_closed_left_panel_and_the_chosen_tool() { assert_eq!(app.mode, Mode::AllTools); assert_eq!(app.left, LeftPanel::Tool("export")); } + +fn three_tab_harness() -> Harness<'static, PdfCraftApp> { + harness(|app| { + for name in ["first.pdf", "middle.pdf", "last.pdf"] { + app.open_bytes(name, None, FIXTURE.to_vec()).unwrap(); + } + }) +} + +fn close_named_tab(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + // The close button has no separate accessible label. Its centre is defined by + // chrome::tab relative to the actual accessible tab rectangle, not a viewport guess. + let pos = h.get_by_label(name).rect().right_center() - egui::vec2(16.0, 0.0); + h.event(egui::Event::PointerMoved(pos)); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +#[test] +fn closing_tabs_keeps_the_selected_document_or_selects_its_neighbor() { + for removed in [0, 1, 2] { + let mut h = three_tab_harness(); + let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect(); + h.get_by_label("middle.pdf").click(); + h.run_steps(3); + assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[1])); + close_named_tab(&mut h, ["first.pdf", "middle.pdf", "last.pdf"][removed]); + let expected = if removed == 1 { ids[2] } else { ids[1] }; + let app = h.state(); + assert_eq!(app.active_ids().map(|(_, id)| id), Some(expected), "removed tab {removed}"); + assert_eq!( + app.views.iter().map(|view| view.id).collect::>(), + ids.iter().copied().enumerate().filter_map(|(i, id)| (i != removed).then_some(id)).collect::>() + ); + assert_eq!(app.session.docs().len(), 2); + assert!(app.session.get(ids[removed]).is_none()); + assert!(app.close_request.is_none(), "clean tabs close without a prompt"); + for doc in app.session.docs() { + assert_eq!(doc.bytes.as_slice(), FIXTURE); + assert!(!doc.dirty); + } + } +} + +#[test] +fn closing_the_last_active_tab_selects_the_previous_one_then_home() { + let mut h = three_tab_harness(); + let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect(); + for (removed, name) in [(2usize, "last.pdf"), (1, "middle.pdf"), (0, "first.pdf")] { + close_named_tab(&mut h, name); + let app = h.state(); + assert_eq!(app.active_ids().map(|(_, id)| id), removed.checked_sub(1).map(|index| ids[index])); + assert_eq!(app.views.len(), removed); + assert_eq!(app.session.docs().len(), removed); + assert!(app.session.get(ids[removed]).is_none()); + } + assert_eq!(h.state().active, None); + h.get_by_label_contains("Welcome to PdfCraft"); +} + +#[test] +fn cancelling_then_discarding_an_earlier_dirty_tab_preserves_the_selected_document() { + let mut h = three_tab_harness(); + let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect(); + h.get_by_label("first.pdf").click(); + h.run_steps(3); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Unsaved title".into() })); + h.run_steps(3); + h.get_by_label("middle.pdf").click(); + h.run_steps(3); + close_named_tab(&mut h, "first.pdf (edited)"); + assert_eq!(h.state().close_request, Some(pdfcraft_ui_egui::CloseRequest::Tab(ids[0]))); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().views.len(), 3); + assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[1])); + assert!(h.state().session.get(ids[0]).unwrap().dirty); + close_named_tab(&mut h, "first.pdf (edited)"); + h.get_by_label("Don't save").click(); + h.run_steps(3); + assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[1])); + assert_eq!(h.state().views.iter().map(|view| view.id).collect::>(), [ids[1], ids[2]]); + assert!(h.state().session.get(ids[0]).is_none()); + assert!(h.state().close_request.is_none()); + for doc in h.state().session.docs() { + assert_eq!(doc.bytes.as_slice(), FIXTURE); + assert!(!doc.dirty); + } +} From 3543ca8296dc52c4703aca96555b9e1c50fa025e Mon Sep 17 00:00:00 2001 From: Wesley Haines Date: Fri, 9 Oct 2026 09:15:13 -0400 Subject: [PATCH 14/23] Edit text: fast drags on a paragraph's edge no longer get dropped (#436) The rewrap handle took the drag target from the pointer's current position. egui only reports drag_started() once the pointer has moved 6 points (or the button was held 0.8 s), so a quick outward flick had already left the 7x14 px handle and the drag was ignored. Resolve the target from press_origin(), as prepare.rs does, before the hover early-return. Also: the left edge rewraps too (width plus an x offset), and edge grips span the box's full height, 6 px either side of the edge. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/edit_text_ui.rs | 112 +++++++++++++++++++++-------- crates/ui-egui/tests/editing.rs | 56 +++++++++++++++ 2 files changed, 138 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 6a455cf47d..6cab546614 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1,7 +1,7 @@ //! Edit a PDF ▸ Edit text & images: boxes around the paragraphs and images already on the page. //! Click a paragraph to edit it in place (⌘Enter or clicking away applies and rewraps it to the -//! box, Esc cancels); drag it to move it, or drag the handle on its right edge to rewrap it to a -//! new width. Click an image to select it: drag to move, drag a corner to resize (keeping +//! box, Esc cancels); drag it to move it, or drag the handle on its left or right edge to rewrap +//! it to a new width. Click an image to select it: drag to move, drag a corner to resize (keeping //! its proportions), right-click for rotate, flip, replace, save and delete; Delete removes it. use egui::{Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke}; @@ -170,19 +170,56 @@ pub struct ImageSelection { drag: Option<(Pos2, Option)>, } -/// A paragraph box being dragged: moved, or (from the handle on its right edge) resized. +/// What a drag on a paragraph box takes hold of. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Grip { + Move, + /// An edge (screen left or right), dragged to rewrap the paragraph. + Left, + Right, +} + +/// A paragraph box being dragged: moved, or (from a handle on its left or right edge) resized. #[derive(Clone, Copy, Debug, PartialEq)] pub struct BlockDrag { page: usize, block: usize, /// Where the drag started (screen). start: Pos2, - resize: bool, + grip: Grip, } -/// The resize handle on a paragraph box's right edge (screen). -fn width_handle(b: Rect) -> Rect { - Rect::from_center_size(Pos2::new(b.right(), b.center().y), egui::vec2(7.0, 14.0)) +/// How far an edge grip reaches either side of a box's edge (screen pixels). +const EDGE_REACH: f32 = 6.0; + +/// The rewrap handles drawn on a paragraph box's left and right edges (screen). +fn width_handles(b: Rect) -> [Rect; 2] { + let h = (b.height() * 0.5).clamp(14.0, 28.0); + [b.left(), b.right()].map(|x| Rect::from_center_size(Pos2::new(x, b.center().y), egui::vec2(7.0, h))) +} + +/// The edge of `b` that `p` grabs: anywhere along its height, within reach of the line. A narrow +/// box keeps its middle for moving. +fn edge_at(b: Rect, p: Pos2) -> Option { + if p.y < b.top() - EDGE_REACH || p.y > b.bottom() + EDGE_REACH { + return None; + } + let inside = EDGE_REACH.min(b.width() / 4.0); + let (dl, dr) = (p.x - b.left(), b.right() - p.x); + match (dl >= -EDGE_REACH && dl <= inside, dr >= -EDGE_REACH && dr <= inside) { + (true, true) if dl <= dr => Some(Grip::Left), + (_, true) => Some(Grip::Right), + (true, false) => Some(Grip::Left), + _ => None, + } +} + +/// The topmost box under `p` and what it grabs there; edges only on an upright page. +fn grab_at(boxes: &[Rect], p: Pos2, upright: bool) -> Option<(usize, Grip)> { + boxes.iter().enumerate().rev().find_map(|(i, b)| match upright.then(|| edge_at(*b, p)).flatten() { + Some(g) => Some((i, g)), + None => b.contains(p).then_some((i, Grip::Move)), + }) } /// What a right-click on a selected image asks for. @@ -360,25 +397,38 @@ pub(crate) fn page_input( // The width handle needs the page upright (or upside down): on a quarter-turned page the // screen's horizontal is the paragraph's vertical. let upright = info.pages.get(page).is_some_and(|p| p.rotation % 180 == 0); - // A drag in progress: the box follows the pointer (or its right edge does); releasing applies it. + let draw_handles = |b: Rect| { + for h in width_handles(b) { + painter.rect(h, CornerRadius::same(2), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); + } + }; + // A drag in progress: the box follows the pointer (or the grabbed edge does); releasing applies it. if let Some(d) = view.block_drag.filter(|d| d.page == page) && let (Some(b), Some(l)) = (boxes.get(d.block).copied(), lines.get(d.block)) { let p = ui.input(|i| i.pointer.interact_pos()).unwrap_or(d.start); - let preview = if d.resize { - Rect::from_min_max(b.min, Pos2::new((b.right() + p.x - d.start.x).max(b.left() + 12.0), b.max.y)) - } else { - b.translate(p - d.start) + let dx = p.x - d.start.x; + let preview = match d.grip { + Grip::Move => b.translate(p - d.start), + Grip::Left => Rect::from_min_max(Pos2::new((b.left() + dx).min(b.right() - 12.0), b.min.y), b.max), + Grip::Right => Rect::from_min_max(b.min, Pos2::new((b.right() + dx).max(b.left() + 12.0), b.max.y)), }; painter.rect_stroke(preview, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - ui.ctx().set_cursor_icon(if d.resize { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Grabbing }); + let resizing = d.grip != Grip::Move; + if resizing { + draw_handles(preview); + } + ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Grabbing }); 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 d.resize { - // The new width in user space: the box's change, added to the paragraph's own. + let style = 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]); - pdfcraft_engine::BlockStyle { width: Some(width), ..Default::default() } + let shift = to[0] - from[0]; + pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() } } else { pdfcraft_engine::BlockStyle { offset: Some([to[0] - from[0], to[1] - from[1]]), ..Default::default() } }; @@ -388,26 +438,28 @@ pub(crate) fn page_input( } return true; } - let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false }; - let Some(hit) = boxes.iter().rposition(|b| b.contains(p) || (upright && width_handle(*b).contains(p))) else { return false }; - if active != Some(hit) { - painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - } - let on_handle = upright && active.is_none() && width_handle(boxes[hit]).contains(p); - if upright && active.is_none() { - painter.rect(width_handle(boxes[hit]), CornerRadius::same(2), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); - } - ui.ctx().set_cursor_icon(if on_handle { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Text }); - // Dragging a box (not while a paragraph is open for typing) moves it; from the handle, resizes it. + // Dragging a box (not while a paragraph is open for typing) moves it; from an edge, resizes it. + // What it grabs comes from where the button went down, not where the pointer is now: egui + // only calls it a drag once the pointer has moved a few pixels (or been held a moment), and a + // quick flick has left the edge by then. if resp.drag_started() && active.is_none() && let Some(o) = ui.input(|i| i.pointer.press_origin()) - && let Some(block) = boxes.iter().rposition(|b| b.contains(o) || (upright && width_handle(*b).contains(o))) + && let Some((block, grip)) = grab_at(&boxes, o, upright) { - let resize = upright && width_handle(boxes[block]).contains(o); - view.block_drag = Some(BlockDrag { page, block, start: o, resize }); + view.block_drag = Some(BlockDrag { page, block, start: o, grip }); return true; } + let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false }; + let Some((hit, grip)) = grab_at(&boxes, p, upright) else { return false }; + if active != Some(hit) { + painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + } + let on_edge = active.is_none() && grip != Grip::Move; + if upright && active.is_none() { + draw_handles(boxes[hit]); + } + ui.ctx().set_cursor_icon(if on_edge { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Text }); if resp.clicked() { let l = &lines[hit]; // Screen pixels per point, from the box's width. diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 99c0ec118b..524e42decb 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1629,6 +1629,62 @@ fn dragging_a_paragraph_moves_it_and_its_edge_rewraps_it() { assert!(h.state().views[0].line_editor.is_none()); } +/// 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); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + h.hover_at(to); + h.run_steps(1); + h.drop_at(to); + h.run_steps(4); +} + +#[test] +fn a_quick_flick_on_either_edge_rewraps_the_paragraph() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let lines = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().text_lines(0) + }; + let block = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().text_blocks(0)[0].clone() + }; + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 200.0; + let screen = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k); + let near = |a: f64, b: f64| (a - b).abs() < 1.5; + // Grab edges near the box's top: a rewrapped box reaches the window's bottom, where the + // tool's toast sits over the page and takes the press. + let grip_y = |b: &pdfcraft_engine::TextBlock| b.rect[3] - 6.0; + // Narrow "Page 1" from its right edge to about 45 pt: it rewraps onto two lines. + let b = block(&h); + let edge = screen(b.rect[2], grip_y(&b)) + egui::vec2(2.0, 0.0); + flick(&mut h, edge, edge - egui::vec2((b.rect[2] - b.rect[0] - 45.0) as f32 * k, 0.0)); + let text: Vec = lines(&h).iter().map(|l| l.text.clone()).collect(); + assert_eq!(text, ["Page", "1"], "narrowed from the right edge"); + // Flick the right edge outward, past the box and its handle: back onto one line. + let b = block(&h); + let edge = screen(b.rect[2], grip_y(&b)) + egui::vec2(2.0, 0.0); + flick(&mut h, edge, edge + egui::vec2(80.0 * k, 0.0)); + let text: Vec = lines(&h).iter().map(|l| l.text.clone()).collect(); + assert_eq!(text, ["Page 1"], "widened by a fast drag that left the handle at once"); + // Flick the left edge inward: it rewraps and its left side follows the pointer. + let b = block(&h); + let edge = screen(b.rect[0], grip_y(&b)) - egui::vec2(2.0, 0.0); + let inward = (b.rect[2] - b.rect[0] - 45.0) as f32; + flick(&mut h, edge, edge + egui::vec2(inward * k, 0.0)); + let after = lines(&h); + let text: Vec = after.iter().map(|l| l.text.clone()).collect(); + assert_eq!(text, ["Page", "1"], "narrowed from the left edge"); + assert!(near(after[0].rect[0], b.rect[0] + inward as f64), "{:?} → {:?}", b.rect, after[0].rect); + assert!(h.state().views[0].line_editor.is_none()); +} + /// The Pages panel, in a window tall enough to show every thumbnail of a short fixture. fn pages_panel(pages: usize) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1900.0)).build_eframe(move |_cc| { From c8c7959c0fac4b132f6f6a14c28fa1b5e0caedd4 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:15:18 +0900 Subject: [PATCH 15/23] M4.6: search bookmark titles without changing the outline (#424) Refs #257. Show matching entries and their ancestors, keep original edit paths and preserve per-document view state. Assisted-by: OpenAI GPT-6.1 (gpt-6.1-sol) --- README.md | 2 +- crates/ui-egui/src/panels.rs | 69 ++++++++++++++++++++++++++++++- crates/ui-egui/tests/bookmarks.rs | 65 +++++++++++++++++++++++++++++ crates/ui-egui/tests/control.rs | 29 +++++++++++++ 4 files changed, 162 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 24db83c6f0..74deff1e19 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Search the whole document as you type, step through matches with ⌘G ## Navigate long documents -Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. +Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 27ac404e8e..20ab700321 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -486,6 +486,37 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); }); ui.add_space(6.0); + let search_id = egui::Id::new(("bookmark_search", id.0)); + let mut bookmark_query = ui.data(|d| d.get_temp::(search_id)).unwrap_or_default(); + if panel == RightPanel::Bookmarks && !info.outline.is_empty() { + ui.horizontal(|ui| { + let label = ui.label(tl!("Search")); + let previous = bookmark_query.clone(); + let response = + ui.add(egui::TextEdit::singleline(&mut bookmark_query).desired_width(ui.available_width() - 32.0)).labelled_by(label.id); + response.widget_info(|| { + let mut info = egui::WidgetInfo::text_edit(ui.is_enabled(), &previous, &bookmark_query, ""); + info.label = Some(tl!("Search").to_string()); + info + }); + if icons::button(ui, "x", 26.0, false, tl!("Clear")).clicked() { + bookmark_query.clear(); + } + }); + ui.data_mut(|d| d.insert_temp(search_id, bookmark_query.clone())); + ui.add_space(6.0); + } + let bookmark_query = bookmark_query.trim().to_lowercase(); + let mut bookmark_matches = + if panel == RightPanel::Bookmarks { outline_matches(&info.outline, &bookmark_query) } else { std::collections::HashSet::new() }; + // A new bookmark must stay nameable even when its initial title doesn't match. + if let Some((path, _)) = &bm_rename { + let mut ancestor = path.clone(); + while !ancestor.is_empty() { + bookmark_matches.insert(ancestor.clone()); + ancestor.pop(); + } + } egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match panel { RightPanel::Comments => { if let Some(e) = crate::comments_panel::show(ui, &t, info, view, prefs, comment_allowed, &mut nav) { @@ -503,7 +534,12 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { editable: bm_editable, current: view.current, expand: bm_expand, + matches: &bookmark_matches, + searching: !bookmark_query.is_empty(), }; + if ctx.searching && bookmark_matches.is_empty() && !info.outline.is_empty() { + ui.label(tl!("No matches.")); + } for (i, item) in info.outline.iter().enumerate() { outline_item(ui, &t, info, item, &[i], info.outline.len(), &mut ctx); } @@ -673,6 +709,30 @@ pub enum BmAction { Outdent(Vec), } +/// Matching titles plus their ancestors, keeping document paths rather than filtered indexes. +fn outline_matches(items: &[OutlineItem], query: &str) -> std::collections::HashSet> { + let mut matches = std::collections::HashSet::new(); + if query.is_empty() { + return matches; + } + let mut pending: Vec<_> = items.iter().enumerate().map(|(i, item)| (vec![i], item)).collect(); + while let Some((path, item)) = pending.pop() { + if item.title.to_lowercase().contains(query) { + let mut ancestor = path.clone(); + while !ancestor.is_empty() { + matches.insert(ancestor.clone()); + ancestor.pop(); + } + } + for (i, child) in item.children.iter().enumerate() { + let mut child_path = path.clone(); + child_path.push(i); + pending.push((child_path, child)); + } + } + matches +} + struct OutlineCtx<'a> { nav: &'a mut Option