From 057e453a16c0750ff1c6a784003587f7d2247baa Mon Sep 17 00:00:00 2001 From: Jules Kreuer <25013642+not-a-feature@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:49:57 +0200 Subject: [PATCH 01/59] Retire inherited resources (#1348) * M8: Retire inherited redaction resources * Redaction: pin inherited resources only on redacted pages, shared by reference localize_resources rewrote every page in the document on every apply and copied a direct inherited /Resources dictionary into each one, so a large document grew by N x the dict. Now only redacted pages that inherit resources are pinned; a direct inherited dictionary is made one indirect object and shared by reference. The parent's /Resources is retired when no other page still inherits it (the leak fix), and kept (through the shared reference) when an unredacted page still draws with it. A /Parent chain that can't be followed or disagrees with the page tree retires nothing. Tests: an unredacted page is left untouched and the rewrite is bounded; redacting every inheriting page retires the parent's resources. ROADMAP entry moved into the revision-history table. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/redact/README.md | 5 ++ crates/redact/src/lib.rs | 94 +++++++++++++++++++++++++++++++++++++ crates/redact/src/tests.rs | 96 ++++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 2 +- 5 files changed, 197 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 44a26604d8..1ff5c9af70 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -403,6 +403,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | M10: borderless inferred tables and positioned invoice watermarks (#773); estimates unchanged. | diff --git a/crates/redact/README.md b/crates/redact/README.md index c156ad0fb6..4a569eaa18 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -36,6 +36,11 @@ leave it unredacted. Structure cleanup resolves named MCIDs and indirect content references. Form content references are updated to the cleaned copy; unchanged sibling descriptions remain. +A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; +a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's +`/Resources` is then retired unless another page still inherits it, so it cannot keep a removed +XObject reachable. Pages that aren't redacted are left untouched. + ## Remove hidden information / Sanitize ```rust diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 7ce4cfd167..15f839691e 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -14,6 +14,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::collections::{HashMap, HashSet}; + use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; pub mod codes; @@ -272,6 +274,97 @@ fn overlay_content(marks: &[&Mark]) -> Vec { c } +/// The nearest ancestor of `page` that holds `/Resources` (where an inheriting page gets them): +/// `Some(None)` when none does, `None` when the chain can't be followed (a cycle, a broken node or +/// a tree deeper than any real one), so callers can stay conservative. +fn resource_source(doc: &Document, page: ObjRef) -> Option> { + let mut seen = HashSet::new(); + let mut parent = doc.get(page).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref); + while let Some(r) = parent { + if !seen.insert(r) || seen.len() > 4096 { + return None; + } + let node = doc.get(r); + let d = node.as_dict()?; + if d.contains(b"Resources") { + return Some(Some(r)); + } + parent = d.get(b"Parent").and_then(Object::as_ref); + } + Some(None) +} + +/// Before rewriting `selected` pages, pin the resources they inherit onto them, and retire the +/// ancestor `/Resources` they came from once no other page still inherits it, so the parent can't +/// keep a removed XObject reachable. Pages that aren't redacted are left untouched; an inherited +/// dictionary that is direct becomes one shared indirect object, never a copy per page. +fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfcraft_cos::CosError> { + let pages = pdfcraft_model::pages(doc); + let inherits = |doc: &Document, p: &pdfcraft_model::Page| doc.get(p.obj).as_dict().is_some_and(|d| !d.contains(b"Resources")); + // Ancestor → the reference its resources are shared through, and whether that reference is new. + let mut shared: HashMap = HashMap::new(); + for page in selected.iter().filter_map(|&i| pages.get(i)) { + // What the page tree gives the page; nothing to pin if it gets nothing. + let Some(effective) = page.dict.get(b"Resources").cloned() else { continue }; + if !inherits(doc, page) { + continue; + } + let source = resource_source(doc, page.obj).flatten(); + let original = source.and_then(|s| doc.get(s).as_dict().and_then(|d| d.get(b"Resources").cloned())); + let value = match source.filter(|_| original.as_ref() == Some(&effective)) { + // `/Parent` agrees with the page tree: share one reference per ancestor. + Some(source) => match shared.get(&source) { + Some((v, _)) => v.clone(), + None => { + // A direct dictionary is made indirect once, so every pinned page shares it. + let (v, new) = match effective { + Object::Ref(_) => (effective, false), + direct => (Object::Ref(doc.add(direct)), true), + }; + shared.insert(source, (v.clone(), new)); + v + } + }, + // A malformed tree whose `/Parent` disagrees: pin this page alone and retire nothing. + None => match effective { + Object::Ref(_) => effective, + direct => Object::Ref(doc.add(direct)), + }, + }; + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), value))?; + } + if shared.is_empty() { + return Ok(()); + } + // Ancestors some page still inherits from keep their resources (that page draws them), through + // the shared reference; the others are retired. A page that inherits resources through a chain + // that can't be followed might inherit from any of them, so then none is retired. + let mut still_used: HashSet = HashSet::new(); + for page in pages.iter().filter(|p| p.dict.contains(b"Resources") && inherits(doc, p)) { + match resource_source(doc, page.obj) { + Some(Some(source)) => { + if shared.contains_key(&source) { + still_used.insert(source); + } + } + _ => still_used.extend(shared.keys().copied()), + } + } + for (source, (value, new)) in shared { + if still_used.contains(&source) { + if !new { + continue; + } + doc.update_dict(source, |d| d.set(b"Resources".to_vec(), value))?; + } else { + doc.update_dict(source, |d| { + d.remove(b"Resources"); + })?; + } + } + Ok(()) +} + /// Apply every redaction mark (or only those on `pages`, 0-based). Irreversible for the saved /// file; callers keep the previous document for undo. pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result { @@ -293,6 +386,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = Vec::new(); let widget_owner: Vec<(ObjRef, String)> = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()).collect(); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 997e6f45a1..81b3b328c0 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -726,3 +726,99 @@ fn form_tags_are_cleaned_and_surviving_references_point_at_the_copy() { assert!(String::from_utf8(s.decoded().unwrap()).unwrap().contains("Public")); assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText")); } + +#[test] +fn inherited_resources_do_not_keep_the_original_form() { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (LEAK_INHERITED) Tj ET"); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + let resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().clone(); + doc.update_dict(ObjRef::new(2, 0), |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.remove(b"Resources"); + }) + .unwrap(); + mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); + apply(&mut doc, None).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources")); +} + +#[test] +fn unselected_pages_keep_inherited_forms() { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let resources = page.remove(b"Resources").unwrap(); + let second = doc.add(Object::Dict(page)); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Resources".to_vec(), resources); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.remove(b"Resources"); + }) + .unwrap(); + mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); + apply(&mut doc, Some(&[0])).unwrap(); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0); + assert_eq!(under(&mut saved, 1, &[[0.0, 190.0, 300.0, 220.0]]), 6); +} + +/// A page tree of `n` pages that all draw form /Fm and inherit a direct `/Resources` from the root. +fn inheriting_pages(n: usize) -> (Document, Vec) { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let resources = page.remove(b"Resources").unwrap(); + doc.set(ObjRef::new(3, 0), Object::Dict(page.clone())); + let mut kids = vec![ObjRef::new(3, 0)]; + kids.extend((1..n).map(|_| doc.add(Object::Dict(page.clone())))); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Resources".to_vec(), resources); + d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect())); + d.set(b"Count".to_vec(), Object::Int(n as i64)); + }) + .unwrap(); + let saved = reopen(&doc); + (saved, kids) +} + +#[test] +fn redacting_one_page_leaves_the_other_pages_untouched() { + let (mut doc, kids) = inheriting_pages(50); + mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); + let before = doc.modified_objects(); + apply(&mut doc, Some(&[0])).unwrap(); + let changed: Vec = doc.modified_objects().into_iter().filter(|n| !before.contains(n)).collect(); + for kid in &kids[1..] { + assert!(!changed.contains(&kid.num), "unredacted page {kid:?} was rewritten"); + } + // The page, its new content and resources, the cleaned form, the parent and one shared + // resources object: not one object per page. + assert!(changed.len() < 10, "{changed:?}"); + // The parent keeps the resources the other pages draw with, through a single shared object. + let parent = doc.get(ObjRef::new(2, 0)).as_dict().unwrap().clone(); + assert!(parent.get(b"Resources").and_then(Object::as_ref).is_some()); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0); + assert_eq!(under(&mut saved, 49, &[[0.0, 190.0, 300.0, 220.0]]), 6); +} + +#[test] +fn redacting_every_inheriting_page_retires_the_parent_resources() { + let (mut doc, kids) = inheriting_pages(20); + for page in 0..kids.len() { + mark(&mut doc, page, &[[0.0, 190.0, 300.0, 220.0]], ""); + } + apply(&mut doc, None).unwrap(); + assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources")); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + for page in 0..kids.len() { + assert!(pdfcraft_model::pages(&saved)[page].dict.contains(b"Resources")); + assert_eq!(under(&mut saved, page, &[[0.0, 190.0, 300.0, 220.0]]), 0); + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 9c6d39c9b1..b483b336d7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -93,7 +93,7 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | -| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | From fcc1d9ead0f4c850a8c71637a90a92aa1f871004 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:59:51 +0200 Subject: [PATCH 02/59] M2: adapt Lab Bradford white points in LMS (#1417) Follow up #1237 by deriving Bradford scaling ratios from the source and D65 white points in LMS. Preserve CalRGB and the merged Indexed/Range handling. Add independent neutral/chromatic, identity, asymmetric Range, and degenerate-cone render regressions. Co-authored-by: Codex --- ROADMAP.md | 1 + crates/render/src/lab_bradford_tests.rs | 113 ++++++++++++++++++++++++ crates/render/src/lib.rs | 3 + docs/roadmap.md | 2 +- vendor/README.md | 7 ++ vendor/hayro-interpret/src/color.rs | 23 ++++- 6 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 crates/render/src/lab_bradford_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1ff5c9af70..78ad856a9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,6 +144,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. - **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. - **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. +- **2026-10-12 (M2, Lab Bradford follow-up):** Lab white-point adaptation scales Bradford LMS cone responses using LMS white-point ratios. Independent D50 and equal-energy neutral/chromatic render oracles, D65 identity, direct/Indexed consistency with asymmetric chromatic Range, and degenerate-cone fallback cover the correction. CalRGB and the merged Indexed/Range scaling retain their behavior. Estimates unchanged. - **2026-10-12 (community, M14, recovery metadata):** A recovery snapshot whose metadata is missing, damaged or names another snapshot is no longer deleted when the folder is listed: it is offered for recovery without an original path (Save asks for a destination), and encrypted snapshots still ask for their password. Recovering one writes fresh metadata in place of the damaged file, and clearing the folder also removes metadata left without a snapshot. Before, a failed metadata write meant the next start deleted the finished snapshot. Synthetic regressions cover the failed write, missing, corrupt, mismatched and encrypted cases. Estimates unchanged. - **2026-10-12 (M7, Arabic ActualText):** Add text records each shaped line in typing order with `/ActualText`, so copy and search preserve mixed Arabic, Latin, digits and brackets. Edit text keeps the logical order supplied by `/ActualText` instead of reversing Arabic or Hebrew a second time. Adjacent visual glyph runs retain their own bidi recovery. Font-independent tests cover Arabic marks, numbers, Latin words, reopen and complete replacement; automation checks saved editor text and unchanged rendering. Font fallback and bidi typing remain separate gaps; estimates unchanged. - **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged. diff --git a/crates/render/src/lab_bradford_tests.rs b/crates/render/src/lab_bradford_tests.rs new file mode 100644 index 0000000000..7ab1e7ed34 --- /dev/null +++ b/crates/render/src/lab_bradford_tests.rs @@ -0,0 +1,113 @@ +//! Independent flat-patch oracles for the Lab Bradford follow-up to #1237. +use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use std::sync::Arc; + +// These frozen RGB8 values were computed outside the renderer in f64 from the +// CIE Lab inverse, the full XYZ adaptation matrix B^-1 diag(B*D65 / B*white) B, +// and the standard sRGB matrix/transfer function. No production helper is used. +// Bradford reference: https://www.color.org/chadtag/ (ICC TN 02-2003). +// White points: D50 [0.96422, 1, 0.82521], E [1, 1, 1], +// D65 [0.95047, 1, 1.08883]. D65 adaptation is the identity. +const SAMPLES: [[f64; 3]; 4] = [[50.0, 0.0, 0.0], [40.0, 20.0, -60.0], [70.0, -50.0, 30.0], [100.0, 0.0, 0.0]]; +const D50: [[u8; 3]; 4] = [[119, 119, 119], [67, 86, 194], [66, 194, 113], [255, 255, 255]]; +const E: [[u8; 3]; 4] = [[119, 119, 119], [50, 88, 194], [64, 194, 114], [255, 255, 255]]; +const D65: [[u8; 3]; 4] = [[119, 119, 119], [19, 90, 194], [77, 193, 114], [255, 255, 255]]; + +fn patch(space: &str, operands: &str) -> [u8; 4] { + let content = format!("/C cs {operands} sc 0 0 20 20 re f"); + let bytes = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Resources << /ColorSpace << /C {space} >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert!(page.error.is_none(), "{space} {operands}: {:?}", page.error); + assert_eq!((page.width, page.height), (20, 20)); + page.rgba.get((10 * 20 + 10) * 4..(10 * 20 + 10) * 4 + 4).unwrap().try_into().unwrap() +} + +fn assert_rgb(actual: [u8; 4], expected: [u8; 3], context: &str) { + assert_eq!(actual[3], 255, "{context}: {actual:?}"); + for (got, want) in actual.into_iter().zip(expected) { + assert!(got.abs_diff(want) <= 1, "{context}: got {actual:?}, expected {expected:?}"); + } +} + +fn check_white(white: &str, expected: [[u8; 3]; 4]) { + let space = format!("[/Lab << /WhitePoint {white} /Range [-100 100 -100 100] >>]"); + for (sample, expected) in SAMPLES.into_iter().zip(expected) { + let operands = format!("{} {} {}", sample[0], sample[1], sample[2]); + assert_rgb(patch(&space, &operands), expected, &format!("{white} Lab {sample:?}")); + } +} + +#[test] +fn lab_bradford_d50_neutral_and_chromatic_oracles() { + check_white("[0.96422 1 0.82521]", D50); +} + +#[test] +fn lab_bradford_equal_energy_neutral_and_chromatic_oracles() { + check_white("[1 1 1]", E); +} + +#[test] +fn lab_bradford_d65_identity_oracles() { + check_white("[0.95047 1 1.08883]", D65); +} + +#[test] +fn lab_bradford_indexed_matches_direct_and_oracle() { + for (white, blue) in [("[0.96422 1 0.82521]", D50[1]), ("[1 1 1]", E[1]), ("[0.95047 1 1.08883]", D65[1])] { + // <66 99 33> maps exactly to L*=40, a*=20, b*=-60. + let lab = format!("[/Lab << /WhitePoint {white} /Range [-100 100 -100 100] >>]"); + let indexed = patch(&format!("[/Indexed {lab} 0 <669933>]"), "0"); + assert_rgb(indexed, blue, &format!("{white} Indexed blue")); + assert_eq!(indexed, patch(&lab, "40 20 -60"), "{white} direct/Indexed blue"); + + // A non-default Range maps <80 80 80> to L*=128/255*100, a*=b*=0. + let lab = format!("[/Lab << /WhitePoint {white} /Range [-128 127 -128 127] >>]"); + let indexed = patch(&format!("[/Indexed {lab} 0 <808080>]"), "0"); + assert_rgb(indexed, [119, 119, 119], &format!("{white} Indexed neutral")); + assert_eq!(indexed, patch(&lab, "50.19607843137255 0 0"), "{white} direct/Indexed neutral"); + } +} + +#[test] +fn lab_bradford_degenerate_cones_fall_back_to_unadapted_xyz() { + // In f32, B*[1, 1, 7.1964064] has a zero L cone response. + // B*[3e38, 3e38, 3e38] overflows its L/M responses. The guard must + // return the sample XYZ in both cases. Independent unadapted XYZ -> + // sRGB references for Lab (50, 0, 0) are frozen below. + for (white, expected) in [ + ("[1 1 7.1964064]", [0, 130, 255]), + ( + "[300000000000000000000000000000000000000 300000000000000000000000000000000000000 300000000000000000000000000000000000000]", + [255, 255, 255], + ), + ] { + let space = format!("[/Lab << /WhitePoint {white} >>]"); + assert_rgb(patch(&space, "50 0 0"), expected, &format!("{white} degenerate cones")); + } +} + +#[test] +fn lab_bradford_asymmetric_chromatic_range_matches_direct_and_oracle() { + // With a* [-40, 70] and b* [-90, 20], <80 99 33> maps to + // L*=128/255*100, a*=26, b*=-68. Frozen independent D50 oracle: + // RGB (98, 108, 237). The unequal, asymmetric ranges expose swaps + // and default-Range scaling that a neutral lookup cannot detect. + let lab = "[/Lab << /WhitePoint [0.96422 1 0.82521] /Range [-40 70 -90 20] >>]"; + let indexed = patch(&format!("[/Indexed {lab} 0 <809933>]"), "0"); + assert_rgb(indexed, [98, 108, 237], "D50 asymmetric chromatic Range"); + assert_eq!(indexed, patch(lab, "50.19607843137255 26 -68")); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d2ee5712c4..ae146779ff 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -39,3 +39,6 @@ pub enum OpenError { #[error("{0}")] Unsupported(String), } + +#[cfg(test)] +mod lab_bradford_tests; diff --git a/docs/roadmap.md b/docs/roadmap.md index b483b336d7..42998b70fb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -81,7 +81,7 @@ total matches the parity estimate. |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | 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 | 80% | 20–40 | 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. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | -| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | diff --git a/vendor/README.md b/vendor/README.md index 94109f4438..8fb270f587 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -32,3 +32,10 @@ Later rendering errors, device loss, and non-winit renderer construction are unc Temporary: hayro is the bootstrap renderer and lopdf the bootstrap inspector (ADR-0004); M2.6 replaces them with our own devices and `cos`/`model`. Text extraction: hayro-interpret exposes `OutlineGlyph::is_vertical` from a Type0 font’s CMap writing mode. PdfCraft follows negative glyph-space y for WMode 1 so upright vertical columns can be searched (#126). + +Lab Bradford follow-up to #1237: Lab adapts XYZ to D65 with white-point ratios +computed in Bradford LMS coordinates. CalRGB's existing flat-white and +black-point normalization is unchanged. `lab_bradford_tests` checks independent +D50/equal-energy neutral and chromatic oracles, D65 identity, and direct/Indexed +consistency (including an asymmetric chromatic Range), and zero/overflowing +cone-response fallback. diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 3be29d19d5..dd2edf241d 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -678,6 +678,27 @@ impl Lab { } } + /// Bradford adaptation scales cone responses, so both white points must + /// be expressed in LMS before taking their ratios. CalRGB's separate + /// flat-white/black-point pipeline retains its existing normalization. + fn adapt_to_d65(white_point: &[f32; 3], xyz: &[f32; 3]) -> [f32; 3] { + let source_lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, white_point); + // A malformed but positive XYZ white can still yield a zero or + // overflowing cone response. Leave it unadapted rather than divide by + // zero or propagate a non-finite scale. + if source_lms.iter().any(|v| !v.is_finite() || v.abs() <= f32::EPSILON) { + return *xyz; + } + let destination_lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, &CalRgb::D65_WHITEPOINT); + let lms = CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_MATRIX, xyz); + let adapted = [ + lms[0] * destination_lms[0] / source_lms[0], + lms[1] * destination_lms[1] / source_lms[1], + lms[2] * destination_lms[2] / source_lms[2], + ]; + CalRgb::matrix_product(&CalRgb::BRADFORD_SCALE_INVERSE_MATRIX, &adapted) + } + /// Inverse of the CIE f(t) helper that maps relative fX/fY/fZ to absolute /// XYZ as a ratio of the white point. fn inverse_lab_f(t: f32) -> f32 { @@ -726,7 +747,7 @@ impl ToRgb for Lab { zw * Self::inverse_lab_f(fz), ]; - let xyz_d65 = CalRgb::normalize_white_point_to_d65(&white_point, &xyz); + let xyz_d65 = Self::adapt_to_d65(&white_point, &xyz); let srgb = CalRgb::matrix_product(&CalRgb::SRGB_D65_XYZ_TO_RGB_MATRIX, &xyz_d65); output.copy_from_slice(&[ From c6128c039ac9f840e8c989650c4b90652f62b3a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Bartalos?= Date: Tue, 13 Oct 2026 06:01:50 +0200 Subject: [PATCH 03/59] M7: keep a neighbouring line when replacing text (#1316) (#1427) Glyph boxes of tightly spaced lines overlap, so replacement treated the next line as a duplicate and removed it. A second drawing is removed only when it also shares the edited line's baseline. --- ROADMAP.md | 2 ++ crates/edit/src/tests.rs | 48 ++++++++++++++++++++++++++- crates/edit/src/text.rs | 58 +++++++++++++++++++++------------ crates/edit/src/text_regions.rs | 3 +- docs/gaps.md | 6 +++- docs/roadmap.md | 4 ++- 6 files changed, 95 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 78ad856a9a..1f774cd566 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -141,6 +142,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged. - **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. - **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. - **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 4bcaef8175..f5b37776b3 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_cos::{Document, SaveOptions, write_incremental}; +use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental}; use super::*; @@ -1824,6 +1824,52 @@ fn a_line_drawn_twice_is_replaced_everywhere() { assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), ["Hello there", "world"]); } +#[test] +fn replacing_a_line_keeps_a_distinct_nearby_line() { + // 12 pt lines 6 pt apart: the glyph boxes overlap by more than half the smaller box, + // but the baselines are different lines (#1316). + let src = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -6 Td (Beta) Tj ET"; + let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); + for paragraph in [false, true] { + let mut doc = text_page(src); + assert_eq!(listed(&doc), ["Alpha", "Beta"]); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + if paragraph { + text::rewrite_block(&mut doc, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap(); + } else { + text::replace_line(&mut doc, 0, 0, "ALPHA").unwrap(); + } + assert_eq!(listed(&doc), ["ALPHA", "Beta"]); + assert!(String::from_utf8_lossy(&page_content_bytes(&doc, 0)).contains("(Beta)")); + for bytes in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let back = Document::open(Arc::new(bytes)).unwrap(); + assert!(back.repair_log().is_empty()); + assert_eq!(listed(&back), ["ALPHA", "Beta"]); + } + } + // The same word on the next line is still a different line. + let mut same = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -6 Td (Alpha) Tj ET"); + text::replace_line(&mut same, 0, 0, "ALPHA").unwrap(); + assert_eq!(listed(&same), ["ALPHA", "Alpha"]); + // Ordinary leading, and a paragraph far enough to group apart, keep the neighbour. + let mut spaced = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -14.4 Td (Beta) Tj ET"); + text::replace_line(&mut spaced, 0, 0, "ALPHA").unwrap(); + assert_eq!(listed(&spaced), ["ALPHA", "Beta"]); + let mut apart = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -40 Td (Beta) Tj ET"); + text::rewrite_block(&mut apart, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap(); + assert_eq!(listed(&apart), ["ALPHA", "Beta"]); + // A second copy at the same position is still one replacement. + for paragraph in [false, true] { + let mut duplicate = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj ET BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj ET"); + if paragraph { + text::rewrite_block(&mut duplicate, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap(); + } else { + text::replace_line(&mut duplicate, 0, 0, "ALPHA").unwrap(); + } + assert_eq!(listed(&duplicate), ["ALPHA"]); + } +} + #[test] fn paragraphs_move_and_rewrap_to_a_new_width() { let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET"; diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index cb8b93edfa..981f3c0d0a 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1471,8 +1471,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi } } // Rebuild: the line's first operator becomes the replacement, its others go. Copies of the - // line drawn in the same area go too, so the replacement is all that shows. - let drops = coincident_ops(&lines, std::slice::from_ref(&target.rect)); + // line drawn on the same baseline go too, so the replacement is all that shows. + let drops = coincident_ops(&lines, &[&target]); let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data); let page_ref = p.obj; @@ -1493,27 +1493,44 @@ fn font_size_before(ops: &[Op], at: usize) -> Option { ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1)) } -/// The operators of every line drawn in the same area as one of `rects`. Documents sometimes -/// draw a line more than once (fake bold, an invisible text layer), and a surviving copy would -/// show the old text under the replaced line. Copies overlap a member's rect by more than half of -/// the smaller rect; neighbouring lines share no area. -fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::HashSet { +/// Operators of every line that is another drawing of one of `targets` (fake bold, an invisible +/// text layer). A surviving copy would show the old text under the replacement. +/// +/// A copy overlaps a target by more than half the smaller box and sits on the same baseline +/// (within the tolerance that joins showing operators onto one line). A neighbouring line can +/// share box area when the leading is tighter than the font's ascent plus descent; its baseline +/// is farther away, so it stays. +fn coincident_ops(lines: &[TextLine], targets: &[&TextLine]) -> std::collections::HashSet { let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0); let mut drop = std::collections::HashSet::new(); - for l in lines { - let covered = rects.iter().any(|m| { - let ix = (m[2].min(l.rect[2]) - m[0].max(l.rect[0])).max(0.0); - let iy = (m[3].min(l.rect[3]) - m[1].max(l.rect[1])).max(0.0); - let small = area(*m).min(area(l.rect)); - small > 0.0 && ix * iy / small > 0.5 - }); - if covered { - drop.extend(l.ops.iter().copied()); + for line in lines { + let copy = targets.iter().any(|target| overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target)); + if copy { + drop.extend(line.ops.iter().copied()); } } drop } +fn overlaps_half(a: [f64; 4], b: [f64; 4], area: impl Fn([f64; 4]) -> f64) -> bool { + let ix = (b[2].min(a[2]) - b[0].max(a[0])).max(0.0); + let iy = (b[3].min(a[3]) - b[1].max(a[1])).max(0.0); + let small = area(b).min(area(a)); + small > 0.0 && ix * iy / small > 0.5 +} + +/// Perpendicular distance between baselines, in user space. Horizontal text compares height; +/// turned text compares the distance across the writing direction. +fn on_same_baseline(line: &TextLine, target: &TextLine) -> bool { + let angle = target.rotation().to_radians(); + let (sin, cos) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; + let dx = line.origin.x - target.origin.x; + let dy = line.origin.baseline - target.origin.baseline; + let offset = dx.mul_add(-sin, dy * cos).abs(); + let size = line.size.min(target.size).max(1.0); + offset.is_finite() && offset < size * 0.3 +} + /// The paragraphs on a page (0-based). pub fn text_blocks(doc: &Document, page: usize) -> Result, EditError> { let lines = text_lines(doc, page)?; @@ -1527,8 +1544,8 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), let lines = text_lines(doc, page)?; let blocks = group_blocks(&lines); let target = blocks.get(block).ok_or_else(|| EditError::Invalid("the paragraph no longer exists".into()))?; - let rects: Vec<_> = target.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).collect(); - let mut drops = coincident_ops(&lines, &rects); + let targets: Vec<&TextLine> = target.lines.iter().filter_map(|i| lines.get(*i)).collect(); + let mut drops = coincident_ops(&lines, &targets); drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied())); let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); @@ -1917,10 +1934,9 @@ pub(crate) fn rewrite_block_with_limit( block_ops.push(Op::new("Q", vec![])); } block_ops.push(Op::new("Q", vec![])); - // The paragraph's operators go, and so does anything drawn in the same area (a fake-bold + // The paragraph's operators go, and so does another drawing on the same baseline (a fake-bold // second copy, an invisible text layer): the new text is all that may show. - let member_rects: Vec<[f64; 4]> = members.iter().map(|l| l.rect).collect(); - let mut drop = coincident_ops(&lines, &member_rects); + let mut drop = coincident_ops(&lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); // Text shown earlier in the same text object stays first: the paragraph then goes where it // was, between two halves of the text object, so it keeps its place in reading order. diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 58749afe9c..0213d9040b 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -696,8 +696,7 @@ pub(super) fn rewrite_styled_block( let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; let members: Vec<_> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); - let rects: Vec<_> = members.iter().map(|l| l.rect).collect(); - let mut drop = coincident_ops(lines, &rects); + let mut drop = coincident_ops(lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); let o = &first.origin; let start = first.ops.first().copied().unwrap_or(o.bt_op); diff --git a/docs/gaps.md b/docs/gaps.md index be1000b6a5..0e8b583ded 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (#1316: replacing a line keeps a neighbouring line whose glyph box overlaps; gap 3 remains) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** gap 4 closed: the web build prints through the browser's print dialog; exclusive image-crop resize follow-up; minor (Word invoice regression coverage, #773; Office fidelity still partial; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 13: large-file memory measured, edits keep two copies instead of three, file-backed input blocked on gap 2) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (gap 1 closed by #836; fixed issues removed from gaps 3, 6 and 16) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -87,7 +88,9 @@ update the numbers. and single-face OpenType shaping/bidi have regression coverage. Actual Chinese keyboard insertion/deletion and save/reopen renders are checked on all four private pages. Legacy non-SFNT previews, mixed-font/style contextual runs, structure-tree fidelity and reliable - semantic grouping on every untagged PDF remain open. Estimates are unchanged. + semantic grouping on every untagged PDF remain open. Replacing one line or paragraph no longer + removes a distinct neighbour whose glyph box overlaps (#1316); a second drawing on the same + baseline is still replaced. Estimates are unchanged. - **Missing:** robust in-place editing of text PdfCraft didn't write: subset fonts without the needed glyphs, CJK and right-to-left text, rotated text, lists, superscript/subscript, keeping @@ -312,6 +315,7 @@ update the numbers. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps. Gap 3 remains. Estimates unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-12 | minor | Gap 13: measured large-file memory; edits no longer keep the previous working file while writing the next (peak 3 → 2 copies); file-backed input recorded as blocked on gap 2 | | 2026-10-11 | minor | Closed gap 4: the web build prints through the browser's print dialog (sheets drawn by PdfCraft at 150–300 dpi, one per printed page); number kept | diff --git a/docs/roadmap.md b/docs/roadmap.md index 42998b70fb..32567e34b0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; earlier: exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7: preserve Arabic ActualText typing order; earlier: M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; earlier: exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -88,7 +89,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | @@ -145,6 +146,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M7 #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps; same-baseline copies are still replaced. Estimates unchanged. | | 2026-10-12 | minor | M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection. Estimates unchanged. | | 2026-10-12 | minor | M12: bounded text-only auto-tag preview through the engine and automation; actual PDF structure writing and visual review remain open. Estimates unchanged. | | 2026-10-12 | minor | M7: keep added Arabic copy/search in typing order with per-line ActualText; preserve that order in the editor and keep visual runs separate. Font-independent regressions and tool save/reopen coverage; font fallback and bidi typing remain partial. Estimates unchanged. | From 0d300c6a41a4c2edae02af43e76cfd6be7bc11d0 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:03:47 +0200 Subject: [PATCH 04/59] fix(render): Type 3 text ignores rendering modes other than invisible (vendored hayro-interpret patch 27) (fixes #1469) (#1478) --- crates/render/src/raster.rs | 42 ++++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/interpret/text.rs | 11 ++++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 0e64ac241e..c5145abc1d 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -5885,4 +5885,46 @@ trailer << /Root 1 0 R >> assert!([(5, 5), (50, 50), (95, 95)].iter().all(|(x, y)| px(&p, *x, *y) == red), "{content}: no stray clip"); } } + + /// ISO 32000-1 §9.3.6: only text rendering mode 3 has any effect on a Type 3 font (vendored + /// hayro-interpret patch in `show_glyph`). Print-stream PDFs set `2 Tr` for Type 3 bitmap + /// fonts, and the glyph procedure ran twice (fill, then stroke), so its anti-aliased edges + /// doubled up and the text looked bold; under `1 Tr` an uncoloured glyph took the stroking + /// colour, and under `7 Tr` it disappeared. Both glyph kinds have edges between pixels. + #[test] + fn text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled() { + let render = |glyph: &str, mode: u8| { + let content = format!("BT 1 0 0 RG 6 w 0 g /F1 20 Tf {mode} Tr 10.3 10.3 Td (A) Tj ET"); + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [1] + /Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> >> endobj +6 0 obj << /Length {} >> stream +{glyph} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + glyph.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{glyph} {mode} Tr: {:?}", p.error); + p.rgba + }; + for glyph in ["1 0 0 0 1 1 d1 0.12 0.12 0.71 0.71 re f", "1 0 d0 0.12 0.12 0.71 0.71 re f"] { + let filled = render(glyph, 0); + assert!(filled.as_chunks::<4>().0.iter().any(|p| p[0] < 128), "{glyph}: the glyph paints"); + for mode in [1, 2, 4, 5, 6, 7] { + assert!(render(glyph, mode) == filled, "{glyph}: {mode} Tr draws as 0 Tr"); + } + assert!(render(glyph, 3).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: 3 Tr hides the glyph"); + } + } } diff --git a/vendor/README.md b/vendor/README.md index 8fb270f587..bfbfb89097 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -7,7 +7,7 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content` +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled` | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`) | diff --git a/vendor/hayro-interpret/src/interpret/text.rs b/vendor/hayro-interpret/src/interpret/text.rs index a631ba902d..ddec0400ea 100644 --- a/vendor/hayro-interpret/src/interpret/text.rs +++ b/vendor/hayro-interpret/src/interpret/text.rs @@ -69,7 +69,16 @@ pub(crate) fn show_glyph<'a>( device.set_blend_mode(ctx.get().graphics_state.blend_mode); let stroke_props = ctx.stroke_props(); - match ctx.get().text_state.render_mode { + // PdfCraft patch: only mode 3 has any effect on text in a Type 3 font (ISO 32000-1 + // 9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts + // look bold, and the clip modes have no outline to clip with. + let render_mode = match (glyph, ctx.get().text_state.render_mode) { + (Glyph::Type3(_), TextRenderingMode::Invisible) => TextRenderingMode::Invisible, + (Glyph::Type3(_), _) => TextRenderingMode::Fill, + (_, mode) => mode, + }; + + match render_mode { TextRenderingMode::Fill => { device.draw_glyph( glyph, From 68f8b2d5f78f583356c6cd4000567e4b261c3d7a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:03:55 +0200 Subject: [PATCH 05/59] fix(edit): flattening form fields also drops the ones whose widgets are on no page (fixes #1470) (#1480) --- crates/edit/src/flatten.rs | 32 ++++++++++++++++++++++++-------- crates/edit/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 92cc56d366..7b1865ca80 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -249,33 +249,49 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result = page_list(doc) + .iter() + .filter_map(|p| p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())) + .flatten() + .filter_map(|e| e.as_ref()) + .collect(); + prune_fields(doc, &removed_widgets, orphans.then_some(&on_page))?; } Ok(drawn) } -/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. -fn prune_fields(doc: &mut Document, widgets: &HashSet) -> Result<(), EditError> { +/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. With +/// `on_page`, also the widgets (fields without kids) that are on no page. +fn prune_fields(doc: &mut Document, widgets: &HashSet, on_page: Option<&HashSet>) -> Result<(), EditError> { let Some(root) = doc.root() else { return Ok(()) }; let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; let af_ref = af.as_ref(); let mut af_dict = doc.resolve(&af).as_dict().cloned().unwrap_or_default(); let fields = af_dict.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); // Returns whether the node survives. - fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet, depth: usize) -> bool { + fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet, on_page: Option<&HashSet>, depth: usize) -> bool { if widgets.contains(&r) || depth > 64 { return depth > 64; } let Some(d) = doc.get(r).as_dict().cloned() else { return true }; - let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else { return true }; - let survivors: Vec = kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, depth + 1))).cloned().collect(); + let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else { + return on_page.is_none_or(|p| p.contains(&r)); + }; + let survivors: Vec = + kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, on_page, depth + 1))).cloned().collect(); if survivors.len() != kids.len() { let _ = doc.update_dict(r, |d| d.set(b"Kids".to_vec(), Object::Array(survivors.clone()))); } !survivors.is_empty() } - let survivors: Vec = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, 0))).cloned().collect(); + let survivors: Vec = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, 0))).cloned().collect(); + if survivors.len() == fields.len() && widgets.is_empty() { + return Ok(()); // only looked for fields on no page, and there were none + } af_dict.set(b"Fields".to_vec(), Object::Array(survivors)); match af_ref { Some(r) => doc.set(r, Object::Dict(af_dict)), diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f5b37776b3..2ec5f2f495 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -234,6 +234,34 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() { assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}"); } +/// #1470: a form field whose widget is on no page (left behind by page extraction or combining) +/// has nothing to draw, but flattening every form field still takes it out of `/AcroForm /Fields`, +/// or other software keeps treating the flattened file as a form. A hidden widget on a page is +/// left alone, as before. +#[test] +fn flattening_fields_drops_the_ones_on_no_page() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 7 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (on_page) /Rect [10 10 150 40] /F 4 /AP << /N 10 0 R >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 50 150 80] /F 4 /AP << /N 10 0 R >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Rect [10 90 150 120] /F 2 /AP << /N 10 0 R >> >>", + "<< /FT /Tx /T (parent) /Kids [9 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 130 150 160] /F 4 /AP << /N 10 0 R >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream", + ]); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field on the page is drawn"); + let doc = reopen(&doc); + let root = doc.root().unwrap(); + let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap()); + let fields = doc.resolve(af.as_dict().unwrap().get(b"Fields").unwrap()); + let left: Vec = fields.as_array().unwrap().iter().filter_map(|f| f.as_ref()).collect(); + assert_eq!(left, [pdfcraft_cos::ObjRef::new(7, 0)], "only the hidden field on the page stays"); +} + /// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an /// appearance whose text Helvetica can't draw (another program's), and a comment or form field /// whose appearance PdfCraft drew with `?` (marked `PCUndrawn`). A comment another program drew From 6ed78b9133d7ac879a0cebedf1d99be96707d458 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:04:05 +0200 Subject: [PATCH 06/59] fix: allow links beneath highlight annotations (#1387) (#1481) AI-assisted with OpenAI. Co-authored-by: FredApps --- crates/ui-egui/src/comments.rs | 11 ++ crates/ui-egui/tests/document_links.rs | 186 +++++++++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 623f89b62e..9b07394576 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -931,7 +931,18 @@ fn select_input( // (egui clears the press origin on release, so clicks are located by the pointer.) let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p)); if resp.clicked() && on_page { + let follow_link = cx.tool == QuickTool::Select + && !consumed + && cv.gesture.is_none() + && !resp.double_clicked() + && pointer + .is_some_and(|p| cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p))); match pointer.and_then(|p| cx.hit(p)) { + Some(a) if a.subtype == "Highlight" && follow_link => { + // A plain click follows the link beneath text highlighting. Keep other + // annotations and active gestures in control of their own input. + cv.selected = None; + } Some(a) => { cv.selected = Some((cx.page, a.index)); cv.reveal = true; diff --git a/crates/ui-egui/tests/document_links.rs b/crates/ui-egui/tests/document_links.rs index 0ee9def78d..89d57a8279 100644 --- a/crates/ui-egui/tests/document_links.rs +++ b/crates/ui-egui/tests/document_links.rs @@ -390,3 +390,189 @@ fn a_numeric_host_is_shown_as_the_address_it_goes_to() { h.get_by_label("192.168.1.1"); assert!(h.query_by_label_contains("alphabet").is_none()); } + +fn overlapping_annotation_fixture(subtype: &str, internal: bool) -> Vec { + overlapping_annotation_fixture_with_form(subtype, internal, false) +} + +fn overlapping_annotation_fixture_with_form(subtype: &str, internal: bool, with_form: bool) -> Vec { + let target = if internal { "/Dest [6 0 R /Fit]".to_owned() } else { format!("/A << /S /URI /URI ({LINK_URL}) >>") }; + let mut objs = vec![ + if with_form { + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_owned() + } else { + "<< /Type /Catalog /Pages 2 0 R >>".to_owned() + }, + "<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 /MediaBox [0 0 300 400] >>".to_owned(), + if with_form { + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 7 0 R] >>".to_owned() + } else { + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>".to_owned() + }, + format!("<< /Type /Annot /Subtype /Link /Rect [100 300 250 350] /Border [0 0 0] {target} >>"), + format!( + "<< /Type /Annot /Subtype /{subtype} /Rect [30 300 200 350] /QuadPoints [30 350 200 350 30 300 200 300] /C [1 1 0] /Contents (Review this text) /F 4 >>" + ), + "<< /Type /Page /Parent 2 0 R >>".to_owned(), + ]; + if with_form { + objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (overlap-button) /Rect [100 300 200 350] /P 3 0 R /A << /S /URI /URI ({BUTTON_URL}) >> >>")); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn overlapping_annotation_harness(bytes: Vec) -> 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("highlighted-link.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(8); + h +} + +#[test] +fn highlighted_link_still_requires_confirmation() { + let _gpu = gpu(); + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); + click_link(&mut h); + assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link)); + assert_eq!(h.state().last_opened_url, None); + assert_eq!(h.state().views[0].comments.selected, None); + h.get_by_label("Open link").click(); + h.run_steps(3); + assert_eq!(h.state().last_opened_url.as_deref(), Some(LINK_URL)); + assert_eq!(h.state().pending_link, None); + h.run_steps(3); + assert_eq!(h.state().pending_link, None, "approval must not create another prompt"); +} + +#[test] +fn highlighted_link_cancel_and_escape_do_not_open_url() { + let _gpu = gpu(); + for escape in [false, true] { + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); + click_link(&mut h); + assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link)); + if escape { + h.key_press(egui::Key::Escape); + } else { + h.get_by_label("Cancel").click(); + } + h.run_steps(3); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); + } +} + +#[test] +fn highlighted_internal_link_navigates_after_save_and_reopen() { + let _gpu = gpu(); + let bytes = overlapping_annotation_fixture("Highlight", true); + let mut session = pdfcraft_engine::Session::new(); + let id = session.open("highlighted-link.pdf", None, std::sync::Arc::new(bytes), None).unwrap(); + let saved = session.save_bytes(id).unwrap().as_ref().clone(); + let mut h = overlapping_annotation_harness(saved); + assert_eq!(h.state().views[0].current, 0); + click_link(&mut h); + assert_eq!(h.state().views[0].current, 1); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn highlight_outside_link_remains_selectable() { + let _gpu = gpu(); + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); + let rect = h.state().views[0].page_screen_rect(0).unwrap(); + let at = egui::pos2(rect.min.x + rect.width() * (60.0 / 300.0), rect.min.y + rect.height() * (1.0 - 325.0 / 400.0)); + click_at(&mut h, at); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1))); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn other_annotations_over_links_keep_select_click() { + let _gpu = gpu(); + for subtype in ["Text", "Square"] { + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture(subtype, false)); + click_link(&mut h); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "{subtype}"); + assert_eq!(h.state().pending_link, None, "{subtype}"); + assert_eq!(h.state().last_opened_url, None, "{subtype}"); + } +} + +#[test] +fn form_widget_keeps_priority_over_highlighted_link() { + let _gpu = gpu(); + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture_with_form("Highlight", false, true)); + click_link(&mut h); + assert_eq!(h.state().pending_link, pending(BUTTON_URL, LinkOrigin::Button)); + assert_eq!(h.state().last_opened_url, None); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn active_comment_gesture_keeps_highlighted_link_click() { + let _gpu = gpu(); + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); + let r = h.state().views[0].page_screen_rect(0).unwrap(); + let at = egui::pos2(r.min.x + r.width() * 0.5, r.min.y + r.height() * (1.0 - 325.0 / 400.0)); + h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 1, from: at }); + click_at(&mut h, at); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn highlighted_link_context_click_selects_comment_without_opening_url() { + let _gpu = gpu(); + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); + let r = h.state().views[0].page_screen_rect(0).unwrap(); + let at = egui::pos2(r.min.x + r.width() * 0.5, r.min.y + r.height() * (1.0 - 325.0 / 400.0)); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + } + h.run_steps(3); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1))); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn highlighted_link_can_be_requested_again_after_cancel() { + let _gpu = gpu(); + let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); + for _ in 0..2 { + click_link(&mut h); + assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link)); + assert_eq!(h.state().last_opened_url, None); + h.get_by_label("Cancel").click(); + h.run_steps(120); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); + } +} From ecc4a305d747659f785e34c18307b74fe0b0314b Mon Sep 17 00:00:00 2001 From: Earl Co Date: Tue, 13 Oct 2026 06:04:16 +0200 Subject: [PATCH 07/59] M12: move tags in the order panel (#1482) --- ATTRIBUTION.toml | 42 +++++++++++++-------------- crates/ui-egui/src/i18n/ar.tsv | 2 ++ crates/ui-egui/src/i18n/bg.tsv | 2 ++ crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/el.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/kk.tsv | 2 ++ crates/ui-egui/src/i18n/lt.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/sl.tsv | 2 ++ crates/ui-egui/src/i18n/te.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 2 ++ crates/ui-egui/src/tags_ui.rs | 24 +++++++++++++-- crates/ui-egui/tests/accessibility.rs | 21 ++++++++++++++ parity/acrobat-features.toml | 10 +++---- 25 files changed, 111 insertions(+), 28 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3b08e84401..5c57d5829b 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e04ab5246125efae2bad439e62c150fc10649c4b6796b30565615f5bd7509ec6" +sha256 = "9e72de3704f5faf040d951d39f11e32536c40572cb6af4a2c8456c20d6189b81" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7f600eaa47f23fac4f9f0fece1454e59756c8dc57c7c697502cb02ee21410ac0" +sha256 = "bf481bf7dc5b7bbd51541c5a8c3b2fbeaf545b313bd60070993f057f5b6da7f0" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "37733adc79d7d32a057811b4dc5eb31251ef8ec91f1dac430253c69faac1c33f" +sha256 = "d07268fcab301a296e6e1c56a691331485cbdee92d8082f125df257fbd808ad1" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "285b689b5224a66079738ab39d5a27e5f9ba72e1ab42e148f263b7ea320c4729" +sha256 = "1b433cb74952dd5eb94e0ea685d6e8c0c715ce9609005483b832ff5c549c4623" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e8154a154129ca158396de84d2d432ce1c587477795a6a14985f7834db255744" +sha256 = "a4d149e4a890a2846d18e9193b45f2b40c2b8cc9743ffef6201acdd1aef4c0ca" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "50d4e66209e8b56707bcd148d4644eb0ad9f49dfc195287cfbd851ffbe5c3830" +sha256 = "aab612c6b21b71f45b09e430a046a5d57d3572b20378caad99e960e0847834f9" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "13e677d44d5224e60af28fcc89d8f8af552368ca3c11f0a871498c60fefa7ee2" +sha256 = "b2cd5d1269a160bc9a02d9a8fc8e7bb0819b40101905ca5552066719134b85f1" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e81112d69acc2e1bbb44c5252f8f323b1d637059b5ac5f4c9fcd3eb985a53442" +sha256 = "35f71ba25148c4d187ef5b1fea0c4ad30d9eac8f6634fdad92cce0328584581f" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "df3d3650af984251372bf22b859ba6aca47f26eeff790e834d57a5a4071a1b48" +sha256 = "162544e5cc09a98c3c3ce957223bca08a09f452a0e1ef25192a2ee07a0cec29d" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c9e453382d9d5c83f7b0f986befc6113a62b4736a6b94a6462805cead406753b" +sha256 = "92c062f32c318875153bebd78afef94528ed7a76c074669325e58755e3494e6e" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2a205868b13f719dbc91fdc50f1133f5f317aa2245143bfeaa048190bc9c7d8c" +sha256 = "3a39b915005b8139e0505e09484d3532d4d2b3fe57629a8b691397097507c5f3" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3053aff461c289404bf3009c611037870b945692c65ce8d7c14ad26ac477660c" +sha256 = "d6dea9f180e65bc80219c36bf58f3017d71f7419bac97b554946c5d0048eef90" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "882b3937802bbbd5afe0087264ee4118161351e8828cb02b608488ccfe39bfe9" +sha256 = "026deb06c481650dafcb5237e88f6b60c2ae11df0d6f998727225e3a10fae5aa" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8cca054e167995ca6d95bc45fc0f75385a15a78852e4b173bd4c5b7b651c5a9d" +sha256 = "2f479cac837466414590f8c4ca5e3d4ff28b45890a60421a3ed6d176bbe8a545" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2f4a6429c0c290a53268733dac08da221085992036a4d304d7663d7947d806d3" +sha256 = "a3245989fecc7f8f0adcc19f4290744d291860c175ea8cfb3defa513e6e4708a" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2792f38b1699269b5886fa99a17c7773c3934df95dc4e2db3ba6a475dd652ba0" +sha256 = "7f6558c2fac98c05cc67d16d470d3f64ecf121261a46c42476cc083830472a3a" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "937ac7eac4230c9a1d26d6bd7ef4f08f8d8c9d391a091682e8758fba3ca9678e" +sha256 = "fb6ff20dc580c7e8fd456fce9857a55a1f2bd9ad560ec9aff886e071040a38fa" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7ced08c3485c5eddf92767e6deb009d72351d70cefe60be654cfdac441f40f4b" +sha256 = "fa96cddb9e789d1af12bc0371fd0757c5cbc068884dde19de82d0beeef07ac90" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4e70c6db374db37bf70a17e251675b94128fb11b4d6d20ef669b36fd4ae14ff1" +sha256 = "818aadf33298dda57fc82326ade9d05219a04b1ce2ba38ba9af3c47290e341be" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d2e6be7cf74525310a203bed7434e7f69d2288d337ee696de2882df8da31a746" +sha256 = "60b0f9f46417ec7b164b14269a17d63a88c1419002ebc3942fb3412a47c9cb7d" [[asset]] path = "assets/icons/accessibility.svg" @@ -3351,4 +3351,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6c01a439220415b83acab79c014286e5871ba7946a9c3b9a4776be797d55a086" +sha256 = "228b1032b114d08aa967cc0935c97225f8bd3d95bfa1d64ba71dc99fa1fd470e" diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 8163ee30ad..d8d76205fe 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2359,3 +2359,5 @@ animation effects Off معطّلة The linked PDF opened, but its named destination was not applied. فُتح ملف PDF المرتبط، لكن لم تُطبَّق وجهته المسماة. The linked PDF opened, but its page destination was out of range. فُتح ملف PDF المرتبط، لكن وجهة الصفحة خارج النطاق. Local cross-PDF links require the desktop app تتطلب الروابط المحلية بين ملفات PDF تطبيق سطح المكتب + Move tag earlier نقل العلامة للأعلى + Move tag later نقل العلامة للأسفل diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index febd1f9cec..1355472bcb 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2301,3 +2301,5 @@ animation effects Off Изключени The linked PDF opened, but its named destination was not applied. Свързаният PDF се отвори, но именуваната му цел не беше приложена. The linked PDF opened, but its page destination was out of range. Свързаният PDF се отвори, но целевата му страница е извън обхвата. Local cross-PDF links require the desktop app Локалните връзки между PDF файлове изискват настолното приложение + Move tag earlier Преместване на етикета по-рано + Move tag later Преместване на етикета по-късно diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ee4f9bcb96..7847274817 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2412,3 +2412,5 @@ animation effects Off Vypnuto The linked PDF opened, but its named destination was not applied. Odkazovaný PDF se otevřel, ale jeho pojmenovaný cíl nebyl použit. The linked PDF opened, but its page destination was out of range. Odkazovaný PDF se otevřel, ale cílová stránka je mimo rozsah. Local cross-PDF links require the desktop app Místní odkazy mezi soubory PDF vyžadují desktopovou aplikaci + Move tag earlier Přesunout značku dříve + Move tag later Přesunout značku později diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7811a78ee9..462c08f3f9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2339,3 +2339,5 @@ animation effects Off Aus The linked PDF opened, but its named destination was not applied. Die verknüpfte PDF wurde geöffnet, aber ihr benanntes Ziel wurde nicht angewendet. The linked PDF opened, but its page destination was out of range. Die verknüpfte PDF wurde geöffnet, aber ihr Seitenziel liegt außerhalb des Bereichs. Local cross-PDF links require the desktop app Lokale Verknüpfungen zwischen PDFs erfordern die Desktop-App + Move tag earlier Tag früher verschieben + Move tag later Tag später verschieben diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0a5030e6aa..872a52ea3a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2348,3 +2348,5 @@ animation effects Off Ανενεργά Local cross-PDF links require the desktop app Οι τοπικοί σύνδεσμοι μεταξύ PDF απαιτούν την εφαρμογή υπολογιστή The existing action will be preserved. Η υπάρχουσα ενέργεια θα διατηρηθεί. This form's scripts are turned off because one ran too long. Reopen the document to run them again. Τα σενάρια αυτής της φόρμας είναι απενεργοποιημένα, επειδή ένα από αυτά εκτελούνταν για πολύ ώρα. Ανοίξτε ξανά το έγγραφο για να εκτελεστούν ξανά. + Move tag earlier Μετακίνηση ετικέτας νωρίτερα + Move tag later Μετακίνηση ετικέτας αργότερα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ba79a5b8e0..754acf02fd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2468,3 +2468,5 @@ animation effects Off Desactivados The linked PDF opened, but its page destination was out of range. El PDF vinculado se abrió, pero su página de destino está fuera de rango. Local cross-PDF links require the desktop app Los enlaces locales entre PDF requieren la aplicación de escritorio The existing action will be preserved. Se conservará la acción existente. + Move tag earlier Mover etiqueta antes + Move tag later Mover etiqueta después diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e81b5f01e1..25339bc3ac 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2338,3 +2338,5 @@ animation effects Off Désactivés The linked PDF opened, but its named destination was not applied. Le PDF lié s'est ouvert, mais sa destination nommée n'a pas été appliquée. The linked PDF opened, but its page destination was out of range. Le PDF lié s'est ouvert, mais sa page de destination est hors limites. Local cross-PDF links require the desktop app Les liens locaux entre PDF nécessitent l'application de bureau + Move tag earlier Déplacer la balise plus tôt + Move tag later Déplacer la balise plus tard diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b9b3f558d4..bf5c55842f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2337,3 +2337,5 @@ animation effects Off Ki The linked PDF opened, but its named destination was not applied. A hivatkozott PDF megnyílt, de a nevesített cél nem lett alkalmazva. The linked PDF opened, but its page destination was out of range. A hivatkozott PDF megnyílt, de a céloldal a tartományon kívül esik. Local cross-PDF links require the desktop app A PDF-ek közötti helyi hivatkozásokhoz az asztali alkalmazás szükséges + Move tag earlier Címke korábbra helyezése + Move tag later Címke későbbre helyezése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5448ef6396..b1baa07272 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2347,3 +2347,5 @@ animation effects Off Disattivati The linked PDF opened, but its named destination was not applied. Il PDF collegato è stato aperto, ma la sua destinazione con nome non è stata applicata. The linked PDF opened, but its page destination was out of range. Il PDF collegato è stato aperto, ma la pagina di destinazione è fuori intervallo. Local cross-PDF links require the desktop app I collegamenti locali tra PDF richiedono l'app desktop + Move tag earlier Sposta tag prima + Move tag later Sposta tag dopo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c4277720aa..3752984efb 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2338,3 +2338,5 @@ animation effects Off オフ The linked PDF opened, but its named destination was not applied. リンク先の PDF を開きましたが、名前付き移動先は適用されませんでした。 The linked PDF opened, but its page destination was out of range. リンク先の PDF を開きましたが、移動先のページが範囲外です。 Local cross-PDF links require the desktop app PDF 間のローカルリンクにはデスクトップアプリが必要です + Move tag earlier タグを前へ移動 + Move tag later タグを後へ移動 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index fc807c78d5..8d05f7fe9b 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2336,3 +2336,5 @@ animation effects Off Өшірулі The linked PDF opened, but its named destination was not applied. Байланысты PDF ашылды, бірақ оның аталған мақсаты қолданылмады. The linked PDF opened, but its page destination was out of range. Байланысты PDF ашылды, бірақ оның мақсатты беті ауқымнан тыс. Local cross-PDF links require the desktop app PDF файлдары арасындағы жергілікті сілтемелер үшін жұмыс үстелі қолданбасы қажет + Move tag earlier Тегті ертерек жылжыту + Move tag later Тегті кейінірек жылжыту diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 2b6ffa0bff..7a342bb02b 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2582,3 +2582,5 @@ key Esc Esc key Home Home key End End key Space Tarpas + Move tag earlier Perkelti žymą anksčiau + Move tag later Perkelti žymą vėliau diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ee205f5646..3d7713a66d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2318,3 +2318,5 @@ animation effects Off Wyłączone Local cross-PDF links require the desktop app Lokalne łącza między plikami PDF wymagają aplikacji komputerowej The existing action will be preserved. Istniejąca akcja zostanie zachowana. This form's scripts are turned off because one ran too long. Reopen the document to run them again. Skrypty tego formularza są wyłączone, ponieważ jeden z nich działał zbyt długo. Otwórz dokument ponownie, aby znów je uruchomić. + Move tag earlier Przenieś znacznik wcześniej + Move tag later Przenieś znacznik później diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f22ad9c86c..cc05346929 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2352,3 +2352,5 @@ animation effects Off Desativados The linked PDF opened, but its named destination was not applied. O PDF vinculado foi aberto, mas seu destino nomeado não foi aplicado. The linked PDF opened, but its page destination was out of range. O PDF vinculado foi aberto, mas sua página de destino está fora do intervalo. Local cross-PDF links require the desktop app Links locais entre PDFs exigem o aplicativo para desktop + Move tag earlier Mover tag para antes + Move tag later Mover tag para depois diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ac054d026c..6455bbd930 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2251,3 +2251,5 @@ animation effects Off Выкл The linked PDF opened, but its named destination was not applied. Связанный PDF открыт, но его именованное назначение не применено. The linked PDF opened, but its page destination was out of range. Связанный PDF открыт, но страница назначения вне допустимого диапазона. Local cross-PDF links require the desktop app Локальные ссылки между PDF работают только в настольном приложении + Move tag earlier Переместить тег раньше + Move tag later Переместить тег позже diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index e8dc01620c..21223fba53 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2343,3 +2343,5 @@ animation effects Off Izklopljeni The linked PDF opened, but its named destination was not applied. Povezani PDF se je odprl, vendar njegov poimenovani cilj ni bil uporabljen. The linked PDF opened, but its page destination was out of range. Povezani PDF se je odprl, vendar je ciljna stran zunaj obsega. Local cross-PDF links require the desktop app Lokalne povezave med PDF-ji zahtevajo namizno aplikacijo + Move tag earlier Premakni oznako prej + Move tag later Premakni oznako pozneje diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 292b9ae886..69bc4ab684 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2251,3 +2251,5 @@ animation effects Off ఆఫ్ Local cross-PDF links require the desktop app PDFల మధ్య స్థానిక లింక్‌లకు డెస్క్‌టాప్ యాప్ అవసరం The existing action will be preserved. ఉన్న చర్య అలాగే ఉంచబడుతుంది. This form's scripts are turned off because one ran too long. Reopen the document to run them again. ఒక స్క్రిప్ట్ చాలా సేపు నడిచినందున ఈ ఫారమ్ స్క్రిప్ట్‌లు ఆపివేయబడ్డాయి. వాటిని మళ్లీ నడపడానికి పత్రాన్ని మళ్లీ తెరవండి. + Move tag earlier ట్యాగ్‌ను ముందుకు తరలించు + Move tag later ట్యాగ్‌ను వెనుకకు తరలించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0d990dc99e..9886180a73 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2334,3 +2334,5 @@ animation effects Off Kapalı The linked PDF opened, but its named destination was not applied. Bağlantılı PDF açıldı, ancak adlandırılmış hedefi uygulanmadı. The linked PDF opened, but its page destination was out of range. Bağlantılı PDF açıldı, ancak sayfa hedefi aralık dışında. Local cross-PDF links require the desktop app PDF'ler arası yerel bağlantılar masaüstü uygulamasını gerektirir + Move tag earlier Etiketi daha erken taşı + Move tag later Etiketi daha geç taşı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 89aa68d54a..1ad99cfadb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2360,3 +2360,5 @@ animation effects Off Вимкнено The linked PDF opened, but its named destination was not applied. Пов’язаний PDF відкрито, але його іменоване призначення не застосовано. The linked PDF opened, but its page destination was out of range. Пов’язаний PDF відкрито, але сторінка призначення поза межами діапазону. Local cross-PDF links require the desktop app Локальні посилання між PDF працюють лише в настільному застосунку + Move tag earlier Перемістити тег раніше + Move tag later Перемістити тег пізніше diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a1e6e25e28..ee5dc338a5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2302,3 +2302,5 @@ animation effects Off 关 The linked PDF opened, but its named destination was not applied. 已打开链接的 PDF,但未应用其命名目标。 The linked PDF opened, but its page destination was out of range. 已打开链接的 PDF,但其页面目标超出范围。 Local cross-PDF links require the desktop app PDF 之间的本地链接需要桌面应用 + Move tag earlier 将标签前移 + Move tag later 将标签后移 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c9db457e30..9240c6f738 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2212,3 +2212,5 @@ animation effects Off 關 The linked PDF opened, but its page destination was out of range. 已開啟連結的 PDF,但其頁面目的地超出範圍。 Local cross-PDF links require the desktop app PDF 之間的本機連結需要桌面應用程式 The existing action will be preserved. 將保留現有動作。 + Move tag earlier 將標記前移 + Move tag later 將標記後移 diff --git a/crates/ui-egui/src/tags_ui.rs b/crates/ui-egui/src/tags_ui.rs index 718c3f8b38..2fe917f7c3 100644 --- a/crates/ui-egui/src/tags_ui.rs +++ b/crates/ui-egui/src/tags_ui.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use egui::Ui; use pdfcraft_cos::ObjRef; -use pdfcraft_engine::a11y::{TagInfo, TagProperty}; +use pdfcraft_engine::a11y::{TagInfo, TagPlacement, TagProperty}; use pdfcraft_engine::{DocId, Edit}; use crate::{icons, theme::Tokens, widgets}; @@ -181,13 +181,33 @@ pub(crate) fn panel(ui: &mut Ui, t: &Tokens, doc: DocId, tags: &[TagInfo], state }); let edit = state.draft.edit(tag); let mut save = false; + let siblings: Vec<&TagInfo> = tag.parent.map(|parent| tags.iter().filter(|other| other.parent == Some(parent)).collect()).unwrap_or_default(); + let position = siblings.iter().position(|sibling| sibling.object == tag.object); + let before = position.and_then(|position| position.checked_sub(1)).map(|position| siblings[position]); + let after = position.and_then(|position| siblings.get(position + 1)).copied(); ui.horizontal(|ui| { save = ui.add_enabled(can_edit && !state.draft.kind.is_empty() && edit.is_some(), egui::Button::new(tl!("Save tag properties"))).clicked(); if ui.add_enabled(dirty, egui::Button::new(tl!("Revert changes"))).clicked() { state.draft = Draft::from_tag(tag); } }); - (page, if save { edit } else { None }) + let (mut earlier, mut later) = (false, false); + ui.horizontal(|ui| { + earlier = ui.add_enabled(can_edit && !dirty && before.is_some(), egui::Button::new(tl!("Move tag earlier"))).clicked(); + later = ui.add_enabled(can_edit && !dirty && after.is_some(), egui::Button::new(tl!("Move tag later"))).clicked(); + }); + ( + page, + if save { + edit + } else if earlier { + before.map(|anchor| Edit::MoveTag { object: tag.object, anchor: anchor.object, placement: TagPlacement::Before }) + } else if later { + after.map(|anchor| Edit::MoveTag { object: tag.object, anchor: anchor.object, placement: TagPlacement::After }) + } else { + None + }, + ) } fn visible_tags<'a>(tags: &'a [TagInfo], query: &str, collapsed: &BTreeSet) -> Vec<&'a TagInfo> { diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs index 3ac2e2dbfe..cc235c9f31 100644 --- a/crates/ui-egui/tests/accessibility.rs +++ b/crates/ui-egui/tests/accessibility.rs @@ -223,6 +223,27 @@ fn tags_panel_edits_a_tag_property_and_undoes_it() { assert!(h.query_by_label("First figure · 7").is_none()); } +#[test] +fn tags_panel_moves_a_tag_within_its_siblings() { + 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 + }); + h.run_steps(4); + assert!(h.state_mut().execute("a11y.tags")); + h.run_steps(2); + h.get_by_label("Photo · 8").click(); + h.run_steps(1); + h.get_by_label("Move tag earlier").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.tags().into_iter().map(|tag| tag.object.num).collect::>(), [6, 8, 7]); + assert_eq!(doc.can_undo(), Some("Move object")); +} + #[test] fn checker_finding_opens_its_structure_tag() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index eeba76118f..7d8e8703c5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8909,11 +8909,11 @@ title = "Order panel" area = "M" tier = "P1" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["a11y.tags"] +tools = ["accessibility_move_tag"] +evidence = ["crates/ui-egui/tests/accessibility.rs::tags_panel_moves_a_tag_within_its_siblings", "crates/a11y/src/tests.rs::sibling_tags_move_in_reading_order_without_changing_content", "crates/automation/tests/automation.rs::accessibility_check_report_and_fixes_through_tools"] +notes = "The Tags panel moves a selected reachable indirect tag earlier or later among direct siblings, preserving page appearance and making one undoable reading-order edit. It does not yet show page-content order visually, edit direct tag dictionaries, move across parents, or reorder arbitrary content." [[feature]] id = "a11y.content-panel" From 55f76ab767cd7740f99e28b0ca83d0aaecd944e9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:04:23 +0900 Subject: [PATCH 08/59] Desktop: prepare large PDFs off the UI thread (#1493) * #307: split document open into prepare and commit * #307: move desktop open preparation off the frame thread --- crates/engine/src/lib.rs | 113 ++++++--- crates/engine/src/tests.rs | 14 ++ crates/ui-egui/src/control.rs | 11 +- crates/ui-egui/src/lib.rs | 314 +++++++++++++++++++++--- crates/ui-egui/src/open_worker.rs | 208 ++++++++++++++++ crates/ui-egui/src/open_worker_tests.rs | 90 +++++++ crates/ui-egui/src/recovery.rs | 19 ++ 7 files changed, 706 insertions(+), 63 deletions(-) create mode 100644 crates/ui-egui/src/open_worker.rs create mode 100644 crates/ui-egui/src/open_worker_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4a71f7855c..76bdc381b6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2609,6 +2609,34 @@ pub struct Session { date_language: Option, } +/// Immutable session state needed while a document is prepared away from its owning thread. +#[derive(Clone)] +pub struct OpenContext { + now: Option, + js_off: bool, + trust: Arc, +} + +/// A fully prepared document that has not yet been registered in a session. +pub struct PreparedDocument { + document: Document, +} + +impl OpenContext { + /// Prepare a document without mutating the session that supplied this context. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + let name = name.into(); + guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password)) + .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) + } +} + /// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; @@ -2901,13 +2929,45 @@ impl Session { self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) } - pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { - let name = name.into(); - guard(|| self.open_unguarded(name, path, bytes, password)) - .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) + /// Snapshot the session state that affects open-time scripts, dates and signature trust. + pub fn open_context(&self) -> OpenContext { + OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone() } } - fn open_unguarded(&mut self, name: String, path: Option, bytes: Arc>, password: Option<&str>) -> Result { + /// Prepare a document without adding it to this session. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + self.open_context().prepare_open(name, path, bytes, password) + } + + /// Register a prepared document. This is the only open step that mutates the session. + pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId { + self.next_id += 1; + let id = DocId(self.next_id); + let mut document = prepared.document; + document.id = id; + self.docs.push(document); + id + } + + /// Synchronous convenience API used by the CLI and automation. + pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { + let prepared = self.prepare_open(name, path, bytes, password)?; + Ok(self.commit_open(prepared)) + } + + fn prepare_open_unguarded( + context: &OpenContext, + name: String, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)); // The renderer authenticates on its own. It cannot use the owner password of R2–R4 // files, so give it the user password that owner authentication recovers. @@ -2926,7 +2986,7 @@ impl Session { // A dynamic XFA form is a shell around an XML template; lay the template out into real // pages and fields so the rest of the engine works on it. The original bytes stay: the // laid-out form is one appended revision. - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; // Write `work` as one more revision and reopen it: what the document then is. let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; @@ -2995,7 +3055,7 @@ impl Session { _ => None, }; let (bytes, info, cos) = match (&xfa, cos) { - (Some(_), Ok(Ok(cos))) if !self.js_off => match xfa_template.clone() { + (Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() { Some(tpl) => { let mut work = cos.clone(); let pages = info.pages.len(); @@ -3038,24 +3098,22 @@ impl Session { }; // XFA notes survive the document being re-read after edits. let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; - let id = self.push_document(name, path, bytes, info, cos, render_password, password, xfa)?; - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - // A script that ran away on open turns the form's scripts off for this document. - d.xfa_template = if ran_away { None } else { xfa_template }; - d.js_output.append(script_output); - if ran_away { - d.xfa_scripts_off = true; - d.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - note_warnings(&mut d.xfa_warnings, &xfa_warnings); + let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); + // A script that ran away on open turns the form's scripts off for this document. + document.xfa_template = if ran_away { None } else { xfa_template }; + document.js_output.append(script_output); + if ran_away { + document.xfa_scripts_off = true; + document.js_output.errors.push(XFA_SCRIPTS_OFF.into()); } - Ok(id) + note_warnings(&mut document.xfa_warnings, &xfa_warnings); + Ok(PreparedDocument { document }) } /// The last step of opening: build the document record and register it. #[allow(clippy::too_many_arguments)] - fn push_document( - &mut self, + fn build_document( + trust: &Arc, name: String, path: Option, bytes: Arc>, @@ -3064,7 +3122,7 @@ impl Session { render_password: Option, password: Option<&str>, xfa: Option, - ) -> Result { + ) -> Document { let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; let (mut editor, read_only_reason) = match cos { Ok(Ok(cos)) => { @@ -3114,11 +3172,9 @@ impl Session { .unwrap_or_default(); let links = editor.as_ref().map(|e| pdfcraft_annot::links::list(&e.cos)).unwrap_or_default(); let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); - let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, &self.trust, &sig_cache)).unwrap_or_default(); - self.next_id += 1; - let id = DocId(self.next_id); - self.docs.push(Document { - id, + let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); + Document { + id: DocId(0), name, path, bytes, @@ -3135,7 +3191,7 @@ impl Session { added, links, signatures, - trust: self.trust.clone(), + trust: trust.clone(), sig_cache, editor, config, @@ -3146,8 +3202,7 @@ impl Session { fonts_stale: false, xfa_warnings: Vec::new(), edit_notices: Vec::new(), - }); - Ok(id) + } } fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0ec09c2f2c..ff98c2df53 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -36,6 +36,20 @@ fn session_with(n: usize) -> (Session, DocId) { (s, id) } +#[test] +fn prepared_open_does_not_mutate_the_session_until_commit() { + fn assert_send() {} + assert_send::(); + + let mut session = Session::new(); + let prepared = session.prepare_open("prepared.pdf", None, Arc::new(fixture(3)), None).expect("prepares"); + assert!(session.get(DocId(1)).is_none()); + + let id = session.commit_open(prepared); + assert_eq!(id, DocId(1)); + assert_eq!(session.get(id).expect("committed document").info.pages.len(), 3); +} + #[test] fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() { let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]]; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 65a12e1997..d83553105e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -36,7 +36,8 @@ //! for this session without changing the saved preference. `ui.state.default_mode` reports it. //! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference` //! reports the choice and `ui.state.theme` reports the resolved light/dark colours. -//! - `ui.open {path}`: open a file. +//! - `ui.open {path}`: accept a file open; native opens report `pending: true` until they +//! complete and are listed in `ui.state.pending_opens`. //! - `ui.screenshot {region?}`: PNG of the window (base64), optionally cropped to a rect. use std::collections::{HashMap, VecDeque}; @@ -642,9 +643,15 @@ impl Host for crate::PdfCraftApp { Some(json!({ "tab": i, "doc": v.id.0, "name": d.name, "path": d.path, "pages": d.info.pages.len(), "dirty": d.dirty, "active": self.active == Some(i) })) }) .collect(); + #[cfg(not(target_arch = "wasm32"))] + let pending_opens: Vec = + self.pending_opens.iter().map(|pending| json!({ "token": pending.token, "name": pending.name, "path": pending.path })).collect(); + #[cfg(target_arch = "wasm32")] + let pending_opens: Vec = Vec::new(); json!({ "docking": self.docking, "documents": docs, + "pending_opens": pending_opens, "active": active.map(|v| json!({ "doc": v.id.0, "page": v.current + 1, @@ -743,6 +750,8 @@ impl Host for crate::PdfCraftApp { self.open_path(path); if self.views.len() > before || self.password_prompt.is_some() { Ok(json!({ "opened": path, "password_prompt": self.password_prompt.is_some() })) + } else if self.pending_opens.iter().any(|pending| pending.path.as_deref() == Some(path)) { + Ok(json!({ "accepted": path, "pending": true })) } else { Err(self.toast.as_ref().map(|t| t.0.clone()).unwrap_or_else(|| format!("couldn't open {path}"))) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2d4f5b0993..3418fcdf8f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -117,6 +117,8 @@ pub(crate) fn date_text_for_pdf(session: &Session) -> Result { /// The longest author name kept (Preferences ▸ Identity, restored settings). pub(crate) const MAX_AUTHOR_CHARS: usize = 200; +#[cfg(not(target_arch = "wasm32"))] +mod open_worker; pub mod portable; mod protect; mod recovery; @@ -130,6 +132,9 @@ pub mod updates; mod wheel_pager; mod widgets; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod open_worker_tests; + use pdfcraft_engine::{DocId, Session}; pub use canvas::{DocView, RasterMemory}; @@ -392,6 +397,8 @@ pub struct PasswordPrompt { pub name: String, pub path: Option, pub bytes: std::sync::Arc>, + #[cfg(not(target_arch = "wasm32"))] + stamp: Option, pub input: String, pub error: Option, /// A late startup file must stay in the background even after it is unlocked. @@ -406,6 +413,32 @@ pub struct PasswordPrompt { after_open: Option, } +#[cfg(not(target_arch = "wasm32"))] +struct PendingOpen { + token: u64, + receiver: std::sync::mpsc::Receiver, + name: String, + path: Option, + stamp: Option, + activate: bool, + after_open: Option, + recovered: Option, + then: Option<&'static str>, +} + +#[cfg(not(target_arch = "wasm32"))] +struct OpenRequest { + name: String, + path: Option, + stamp: Option, + input: open_worker::Input, + password: Option, + activate: bool, + after_open: Option, + recovered: Option, + then: Option<&'static str>, +} + /// Asks for the permissions password of an open document whose security restricts it, which /// lifts the restrictions (#995). Acrobat asks for it when such a document's security is changed. pub struct UnlockPrompt { @@ -524,6 +557,15 @@ pub struct PdfCraftApp { /// Open documents' files, watched for changes on disk. #[cfg(not(target_arch = "wasm32"))] pub(crate) watch: file_watch::FileWatch, + /// One native worker prepares opened documents without owning app or session state. + #[cfg(not(target_arch = "wasm32"))] + open_worker: Option, + #[cfg(not(target_arch = "wasm32"))] + pending_opens: Vec, + #[cfg(not(target_arch = "wasm32"))] + next_open_token: u64, + #[cfg(all(test, not(target_arch = "wasm32")))] + open_prepare_gate: Option>, /// Folders pinned to Home, and what they held when last listed. pub pinned: folders_ui::PinnedFolders, pub toast: Option<(String, f64)>, @@ -844,6 +886,14 @@ impl PdfCraftApp { reload_changed_files: true, #[cfg(not(target_arch = "wasm32"))] watch: Default::default(), + #[cfg(not(target_arch = "wasm32"))] + open_worker: None, + #[cfg(not(target_arch = "wasm32"))] + pending_opens: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + next_open_token: 0, + #[cfg(all(test, not(target_arch = "wasm32")))] + open_prepare_gate: None, pinned: Default::default(), toast: None, integrated_titlebar: false, @@ -1032,6 +1082,119 @@ impl PdfCraftApp { self.try_open(name, path, std::sync::Arc::new(bytes), None, activate) } + #[cfg(not(target_arch = "wasm32"))] + fn schedule_open(&mut self, request: OpenRequest) -> Result { + if self.open_worker.is_none() { + self.open_worker = Some( + open_worker::OpenWorker::new( + #[cfg(test)] + self.open_prepare_gate.clone(), + ) + .map_err(|e| format!("couldn't start the document-open worker: {e}"))?, + ); + } + let token = self.next_open_token.wrapping_add(1); + let worker = self.open_worker.as_ref().ok_or("the document-open worker is unavailable")?; + if let Some(ctx) = &self.ctx { + worker.set_context(ctx); + } + let receiver = + worker.submit(token, self.session.open_context(), request.name.clone(), request.path.clone(), request.password, request.input)?; + self.next_open_token = token; + self.pending_opens.push(PendingOpen { + token, + receiver, + name: request.name, + path: request.path, + stamp: request.stamp, + activate: request.activate, + after_open: request.after_open, + recovered: request.recovered, + then: request.then, + }); + Ok(token) + } + + #[cfg(not(target_arch = "wasm32"))] + fn poll_pending_opens(&mut self) { + let mut ready = Vec::new(); + let mut waiting = Vec::new(); + let pending_opens = std::mem::take(&mut self.pending_opens); + for pending in pending_opens { + match pending.receiver.try_recv() { + Ok(result) if result.token == pending.token => ready.push((pending, result.kind)), + Ok(_) => waiting.push(pending), + Err(std::sync::mpsc::TryRecvError::Empty) => waiting.push(pending), + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.notify_fmt("Couldn't open {name}: the background worker stopped", &[("name", &pending.name)]); + } + } + } + self.pending_opens = waiting; + for (pending, kind) in ready { + self.finish_pending_open(pending, kind); + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn finish_pending_open(&mut self, pending: PendingOpen, kind: open_worker::ResultKind) { + match kind { + open_worker::ResultKind::ReadFailed(reason) => { + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &pending.name), ("e", &reason)]); + } + open_worker::ResultKind::RemoteFailed(reason) => { + self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &reason)]); + } + open_worker::ResultKind::RecoveryFailed(reason) => { + self.notify_fmt("Couldn't recover {name}: {e}", &[("name", &pending.name), ("e", &reason)]); + } + open_worker::ResultKind::Failed(reason) => { + self.notify_fmt("Couldn't open {name}: {reason}", &[("name", &pending.name), ("reason", &reason)]); + } + open_worker::ResultKind::Conversion(bytes) => { + if let Some(Err(error)) = self.open_converted(&pending.name, &bytes) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]); + } + } + open_worker::ResultKind::Prepared { bytes, result } => match *result { + Ok(prepared) => match self.finish_open(&pending.name, pending.path.clone(), bytes.len(), prepared, pending.activate) { + Ok(id) => { + if pending.path.is_some() { + self.watch.known_as(id, pending.stamp); + } + if let Some(destination) = pending.after_open { + self.apply_remote_destination(destination); + } + if let Some(meta) = &pending.recovered { + self.finish_recovery(meta); + } + if let Some(command) = pending.then { + self.execute(command); + } + } + Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]), + }, + Err(error @ (pdfcraft_render::OpenError::NeedsPassword | pdfcraft_render::OpenError::WrongPassword)) => { + let password_error = + matches!(error, pdfcraft_render::OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string()); + self.password_prompt = Some(PasswordPrompt { + name: pending.name, + path: pending.path, + bytes, + stamp: pending.stamp, + input: String::new(), + error: password_error, + activate: pending.activate, + recovered: pending.recovered, + then: pending.then, + after_open: pending.after_open, + }); + } + Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error.to_string())]), + }, + } + } + fn try_open( &mut self, name: &str, @@ -1041,15 +1204,18 @@ impl PdfCraftApp { activate: bool, ) -> Result<(), String> { use pdfcraft_render::OpenError; - let size = bytes.len(); - let id = match self.session.open(name, path.clone(), bytes.clone(), password) { - Ok(id) => id, + let prepared = match self.session.prepare_open(name, path.clone(), bytes.clone(), password) { + Ok(prepared) => prepared, Err(e @ (OpenError::NeedsPassword | OpenError::WrongPassword)) => { let error = matches!(e, OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string()); + #[cfg(not(target_arch = "wasm32"))] + let stamp = path.as_deref().and_then(file_watch::Stamp::at); self.password_prompt = Some(PasswordPrompt { name: name.to_string(), path, bytes, + #[cfg(not(target_arch = "wasm32"))] + stamp, input: String::new(), error, activate, @@ -1061,6 +1227,18 @@ impl PdfCraftApp { } Err(e) => return Err(e.to_string()), }; + self.finish_open(name, path, bytes.len(), prepared, activate).map(|_| ()) + } + + fn finish_open( + &mut self, + name: &str, + path: Option, + size: usize, + prepared: pdfcraft_engine::PreparedDocument, + activate: bool, + ) -> Result { + let id = self.session.commit_open(prepared); self.password_prompt = None; // The reading-colours preference is app-wide: a newly opened document starts with it. // Idempotent: the engine returns `false` when the document has the setting already. @@ -1115,7 +1293,7 @@ impl PdfCraftApp { let out = self.session.take_js_output(id); self.handle_js(id, out); } - Ok(()) + Ok(id) } #[cfg(not(target_arch = "wasm32"))] @@ -1128,8 +1306,23 @@ impl PdfCraftApp { let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()); match f.bytes() { Ok(bytes) => { - if let Err(e) = self.open_bytes(&name, None, bytes) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); + if self.ctx.is_some() && !self.run_inline { + let result = self.schedule_open(OpenRequest { + name: name.clone(), + path: None, + stamp: None, + input: open_worker::Input::Bytes(std::sync::Arc::new(bytes)), + password: None, + activate: true, + after_open: None, + recovered: None, + then: None, + }); + if let Err(error) = result { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); + } + } else if let Err(error) = self.open_bytes(&name, None, bytes) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); } } Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), @@ -1260,11 +1453,43 @@ impl PdfCraftApp { } return; }; + #[cfg(not(target_arch = "wasm32"))] + if self.ctx.is_some() && !self.run_inline { + let result = self.schedule_open(OpenRequest { + name: p.name.clone(), + path: p.path.clone(), + stamp: p.stamp, + input: open_worker::Input::Bytes(p.bytes.clone()), + password: Some(pw), + activate: p.activate, + after_open: p.after_open.clone(), + recovered: p.recovered.clone(), + then: p.then, + }); + if let Err(error) = result { + let mut prompt = p; + prompt.error = Some(error); + self.password_prompt = Some(prompt); + } + return; + } + #[cfg(not(target_arch = "wasm32"))] + let (path_for_watch, stamp_for_watch) = (p.path.clone(), p.stamp); let after_open = p.after_open.clone(); match self.try_open(&p.name, p.path, p.bytes, Some(&pw), p.activate) { Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]), // A recovered encrypted document is open once its prompt is gone. Ok(()) if self.password_prompt.is_none() => { + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = path_for_watch.as_deref() + && let Some(id) = self + .views + .iter() + .rev() + .find_map(|view| self.session.get(view.id).and_then(|doc| (doc.path.as_deref() == Some(path)).then_some(view.id))) + { + self.watch.known_as(id, stamp_for_watch); + } if let Some(destination) = after_open { self.apply_remote_destination(destination); } @@ -1338,24 +1563,20 @@ impl PdfCraftApp { let path = path.to_string_lossy().into_owned(); let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.clone()); let stamp = file_watch::Stamp::at(&path); - let tabs = self.views.len(); self.startup_superseded = true; - let bytes = match pdfcraft_engine::read_remote_document(std::path::Path::new(&path)) { - Ok(bytes) => bytes, - Err(e) => { - self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &e.to_string())]); - return; - } - }; - if let Err(e) = self.try_open(&name, Some(path), std::sync::Arc::new(bytes), None, true) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); - } else if let Some(prompt) = self.password_prompt.as_mut() { - prompt.after_open = Some(destination); - } else if self.views.len() > tabs { - if let Some(view) = self.views.last() { - self.watch.known_as(view.id, stamp); - } - self.apply_remote_destination(destination); + let result = self.schedule_open(OpenRequest { + name: name.clone(), + path: Some(path.clone()), + stamp, + input: open_worker::Input::RemotePath(std::path::PathBuf::from(path)), + password: None, + activate: true, + after_open: Some(destination), + recovered: None, + then: None, + }); + if let Err(error) = result { + self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &error)]); } } @@ -1386,18 +1607,38 @@ impl PdfCraftApp { let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); // Taken before reading: a build that rewrites the file meanwhile still counts as a change. let stamp = file_watch::Stamp::at(path); - let tabs = self.views.len(); - match std::fs::read(path) { - Ok(bytes) => { - if let Err(e) = self.open_bytes(&name, Some(path.to_string()), bytes) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); - } else if self.views.len() > tabs - && let Some(doc) = self.views.last().map(|v| v.id) - { - self.watch.known_as(doc, stamp); + // Before the first frame there is no interaction to keep responsive; this also preserves + // deterministic inline startup and test flows. Once live, reading and preparation use + // the bounded worker and commit on the UI thread. + if self.ctx.is_none() || self.run_inline { + match std::fs::read(path) { + Ok(bytes) => { + let before = self.views.len(); + if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); + } else if self.views.len() > before + && let Some(doc) = self.views.last().map(|view| view.id) + { + self.watch.known_as(doc, stamp); + } } + Err(error) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]), } - Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), + return; + } + self.startup_superseded = true; + if let Err(error) = self.schedule_open(OpenRequest { + name: name.clone(), + path: Some(path.to_string()), + stamp, + input: open_worker::Input::Path(std::path::PathBuf::from(path)), + password: None, + activate: true, + after_open: None, + recovered: None, + then: None, + }) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); } } @@ -2395,6 +2636,13 @@ impl eframe::App for PdfCraftApp { // Notices raised outside `ui` (opened files, OS events, the control channel) translate too. let lang = i18n::Lang::from_pref(&self.language); i18n::set_current(lang); + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(worker) = &self.open_worker { + worker.set_context(ctx); + } + self.poll_pending_opens(); + } // Chinese wants its own faces before the Japanese ones (one baseline per line). let cjk = theme::CjkPreference::for_language(lang.code()); if !self.styled { diff --git a/crates/ui-egui/src/open_worker.rs b/crates/ui-egui/src/open_worker.rs new file mode 100644 index 0000000000..04bd1fefe5 --- /dev/null +++ b/crates/ui-egui/src/open_worker.rs @@ -0,0 +1,208 @@ +//! Native document reads and preparation run away from the frame thread. + +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Condvar, Mutex, PoisonError}; + +use crate::RecoveryStore; +use pdfcraft_engine::{OpenContext, PreparedDocument, SourceKind}; +use pdfcraft_render::OpenError; + +const MAX_QUEUED_OPENS: usize = 16; + +pub(crate) enum Input { + Path(PathBuf), + RemotePath(PathBuf), + Recovery(RecoveryStore, String), + Bytes(Arc>), +} + +pub(crate) enum ResultKind { + Conversion(Arc>), + Prepared { bytes: Arc>, result: Box> }, + ReadFailed(String), + RemoteFailed(String), + RecoveryFailed(String), + Failed(String), +} + +pub(crate) struct ResultMessage { + pub token: u64, + pub kind: ResultKind, +} + +struct Job { + token: u64, + context: OpenContext, + name: String, + path: Option, + password: Option, + input: Input, + reply: Sender, + #[cfg(test)] + gate: Option>, +} + +struct Queue { + jobs: VecDeque, + shutdown: bool, + repaint: Option, +} + +pub(crate) struct OpenWorker { + queue: Arc<(Mutex, Condvar)>, + #[cfg(test)] + gate: Option>, +} + +impl OpenWorker { + pub fn new(#[cfg(test)] gate: Option>) -> std::io::Result { + let queue = Arc::new((Mutex::new(Queue { jobs: VecDeque::new(), shutdown: false, repaint: None }), Condvar::new())); + let worker_queue = queue.clone(); + std::thread::Builder::new().name("pdfcraft-open".into()).spawn(move || run(worker_queue))?; + Ok(Self { + queue, + #[cfg(test)] + gate, + }) + } + + pub fn set_context(&self, ctx: &egui::Context) { + let (lock, _) = &*self.queue; + let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner); + queue.repaint = Some(ctx.clone()); + } + + pub fn submit( + &self, + token: u64, + context: OpenContext, + name: String, + path: Option, + password: Option, + input: Input, + ) -> Result, String> { + let (reply, receiver) = mpsc::channel(); + let job = Job { + token, + context, + name, + path, + password, + input, + reply, + #[cfg(test)] + gate: self.gate.clone(), + }; + let (lock, ready) = &*self.queue; + let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner); + if queue.shutdown { + return Err("the document-open worker has stopped".into()); + } + if queue.jobs.len() >= MAX_QUEUED_OPENS { + return Err("too many documents are waiting to open".into()); + } + queue.jobs.push_back(job); + ready.notify_one(); + Ok(receiver) + } +} + +impl Drop for OpenWorker { + fn drop(&mut self) { + let (lock, ready) = &*self.queue; + let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner); + queue.shutdown = true; + queue.jobs.clear(); + queue.repaint = None; + ready.notify_one(); + } +} + +fn run(queue: Arc<(Mutex, Condvar)>) { + loop { + let job = { + let (lock, ready) = &*queue; + let mut state = lock.lock().unwrap_or_else(PoisonError::into_inner); + while state.jobs.is_empty() && !state.shutdown { + state = ready.wait(state).unwrap_or_else(PoisonError::into_inner); + } + if state.shutdown { + return; + } + state.jobs.pop_front() + }; + let Some(job) = job else { continue }; + #[cfg(test)] + if let Some(gate) = &job.gate { + gate.pause_before_prepare(); + } + let token = job.token; + let reply = job.reply.clone(); + #[cfg(test)] + let gate = job.gate.clone(); + let kind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| prepare(job))) + .unwrap_or_else(|_| ResultKind::Failed("opening failed unexpectedly in the background worker".into())); + let _ = reply.send(ResultMessage { token, kind }); + let repaint = { + let (lock, _) = &*queue; + lock.lock().unwrap_or_else(PoisonError::into_inner).repaint.clone() + }; + if let Some(ctx) = repaint { + ctx.request_repaint(); + } + #[cfg(test)] + if let Some(gate) = &gate { + let _ = gate.finished.send(()); + } + } +} + +fn prepare(job: Job) -> ResultKind { + let bytes = match &job.input { + Input::Path(path) => match std::fs::read(path) { + Ok(bytes) => Arc::new(bytes), + Err(error) => return ResultKind::ReadFailed(error.to_string()), + }, + Input::RemotePath(path) => match pdfcraft_engine::read_remote_document(path) { + Ok(bytes) => Arc::new(bytes), + Err(error) => return ResultKind::RemoteFailed(error.to_string()), + }, + Input::Recovery(store, key) => match store.read(key) { + Ok(bytes) => Arc::new(bytes), + Err(error) => return ResultKind::RecoveryFailed(error.to_string()), + }, + Input::Bytes(bytes) => bytes.clone(), + }; + if matches!(pdfcraft_engine::source_kind(&job.name, &bytes), Some(SourceKind::Image | SourceKind::Text)) { + return ResultKind::Conversion(bytes); + } + let result = job.context.prepare_open(job.name, job.path, bytes.clone(), job.password.as_deref()); + ResultKind::Prepared { bytes, result: Box::new(result) } +} + +#[cfg(test)] +pub(crate) struct PrepareGate { + pub started: Sender<()>, + release: Mutex>, + pub finished: Sender<()>, +} + +#[cfg(test)] +impl PrepareGate { + pub fn pause_before_prepare(&self) { + let _ = self.started.send(()); + if let Ok(release) = self.release.lock() { + let _ = release.recv(); + } + } +} + +#[cfg(test)] +pub(crate) fn prepare_gate() -> (Arc, Receiver<()>, Sender<()>, Receiver<()>) { + let (started, started_rx) = mpsc::channel(); + let (release_tx, release) = mpsc::channel(); + let (finished, finished_rx) = mpsc::channel(); + (Arc::new(PrepareGate { started, release: Mutex::new(release), finished }), started_rx, release_tx, finished_rx) +} diff --git a/crates/ui-egui/src/open_worker_tests.rs b/crates/ui-egui/src/open_worker_tests.rs new file mode 100644 index 0000000000..329e6ece15 --- /dev/null +++ b/crates/ui-egui/src/open_worker_tests.rs @@ -0,0 +1,90 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use egui_kittest::Harness; +use serde_json::{Value, json}; + +use crate::{PdfCraftApp, control::ControlClient, open_worker}; + +fn fixture(pages: usize) -> Vec { + let mut objects = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string()]; + let kids = (0..pages).map(|page| format!("{} 0 R", 4 + 2 * page)).collect::>().join(" "); + objects.push(format!("<< /Type /Pages /Kids [{kids}] /Count {pages} /MediaBox [0 0 200 300] >>")); + objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + for page in 0..pages { + objects.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * page)); + let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", page + 1); + objects.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + } + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (index, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +fn harness() -> (Harness<'static, PdfCraftApp>, ControlClient) { + let slot: Arc>> = Arc::default(); + let saved = slot.clone(); + let mut harness = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(move |creation| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").expect("language preference"); + *saved.lock().expect("control-client slot") = Some(app.attach_control(&creation.egui_ctx)); + app.open_bytes("existing.pdf", None, fixture(3)).expect("existing document"); + app + }); + harness.run_steps(3); + (harness, slot.lock().expect("control-client slot").take().expect("control client")) +} + +#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +fn request(harness: &mut Harness<'static, PdfCraftApp>, client: &ControlClient, method: &str, params: Value) -> Value { + let reply = client.send(method, params); + for _ in 0..5 { + harness.step(); + if let Ok(result) = reply.try_recv() { + return result.expect("control request succeeded"); + } + } + panic!("{method} was not answered during the held prepare phase"); +} + +#[test] +#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +fn open_prepare_keeps_control_and_existing_tabs_live_until_atomic_commit() { + let (mut harness, client) = harness(); + let (gate, started, release, finished) = open_worker::prepare_gate(); + harness.state_mut().open_prepare_gate = Some(gate); + + let path = std::env::temp_dir().join(format!("pdfcraft-open-worker-{}.pdf", std::process::id())); + std::fs::write(&path, fixture(2)).expect("write test PDF"); + let open_reply = request(&mut harness, &client, "ui.open", json!({ "path": path.to_string_lossy() })); + assert_eq!(open_reply["pending"], true); + started.recv_timeout(Duration::from_secs(5)).expect("worker reached prepare barrier"); + + let state = request(&mut harness, &client, "ui.state", json!({})); + assert_eq!(state["documents"].as_array().map(Vec::len), Some(1)); + assert_eq!(state["documents"][0]["name"], "existing.pdf"); + assert_eq!(state["pending_opens"].as_array().map(Vec::len), Some(1)); + request(&mut harness, &client, "ui.command", json!({ "id": "page.insert_blank" })); + assert_eq!(harness.state().session.get(harness.state().views[0].id).expect("existing tab").info.pages.len(), 4); + assert_eq!(harness.state().views.len(), 1, "prepared state is not visible in the session"); + + release.send(()).expect("release prepare barrier"); + finished.recv_timeout(Duration::from_secs(5)).expect("worker finished preparation"); + harness.step(); + let app = harness.state(); + assert_eq!(app.views.len(), 2, "one prepared document commits exactly once"); + assert_eq!(app.session.get(app.views[1].id).expect("new tab").info.pages.len(), 2); + assert_eq!(app.active, Some(1)); + let _ = std::fs::remove_file(path); +} diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 7a8b8d464e..134f54ecd7 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -199,6 +199,25 @@ impl PdfCraftApp { let Some(store) = self.recovery.clone() else { return }; for key in keys { let Some(meta) = self.recoverable.iter().find(|m| &m.key == key).cloned() else { continue }; + #[cfg(not(target_arch = "wasm32"))] + if self.ctx.is_some() && !self.run_inline { + self.startup_superseded = true; + let result = self.schedule_open(crate::OpenRequest { + name: meta.name.clone(), + path: None, + stamp: None, + input: crate::open_worker::Input::Recovery(store.clone(), key.clone()), + password: None, + activate: true, + after_open: None, + recovered: Some(meta.clone()), + then: None, + }); + if let Err(error) = result { + self.notify_fmt("Couldn't recover {name}: {e}", &[("name", &meta.name), ("e", &error)]); + } + continue; + } let bytes = match store.read(key) { Ok(b) => b, Err(e) => { From 801a2f14880ebdcd74d492ee21b83c2e3902fcd9 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:05:43 +0200 Subject: [PATCH 09/59] fix(ui): dragged added text follows the pointer before release (#1508) * fix(ui): dragged added text follows the pointer before release (fixes #1488) * fix(ui): keep a moved item in place until the page is redrawn (fixes #1488) --- crates/engine/src/text_preview.rs | 12 +++ crates/ui-egui/src/canvas.rs | 11 ++- crates/ui-egui/src/content_ui.rs | 122 +++++++++++++++++++++++++++ crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/tests/content.rs | 135 +++++++++++++++++++++++++++++- 5 files changed, 277 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index d72b49d1e6..d180169677 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -63,4 +63,16 @@ impl Document { Ok(TextEditPreview { page, bytes, config: self.config.clone() }) })? } + + /// The page beneath an added item while it is dragged: `index` counts the page's added + /// items, as `Edit::DeleteContent` does. Read-only, like [`Self::text_edit_preview`]. + pub fn added_content_preview(&self, page: usize, index: usize) -> Result { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + pdfcraft_edit::delete_content(&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(TextEditPreview { page, bytes, config: self.config.clone() }) + })? + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1f9105e213..82c072350b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -394,6 +394,7 @@ pub struct DocView { pub(crate) edit_lines: HashMap)>, pub line_editor: Option, pub(crate) text_background: crate::edit_text_ui::TextBackground, + pub(crate) move_background: crate::content_ui::MoveBackground, /// Edit text & images: the images per page (by document generation), and the selected one. pub(crate) edit_images: HashMap)>, pub(crate) objects: crate::object_ui::ObjectSelection, @@ -635,6 +636,7 @@ impl DocView { edit_lines: HashMap::new(), line_editor: None, text_background: Default::default(), + move_background: Default::default(), edit_images: HashMap::new(), objects: Default::default(), image_selection: None, @@ -780,7 +782,7 @@ impl DocView { } pub fn render_pending(&self) -> bool { - !self.last_queue.is_empty() || self.text_background.pending() + !self.last_queue.is_empty() || self.text_background.pending() || self.move_background.pending() } /// Re-render every page and text layer (the document's appearance changed, e.g. a layer @@ -1343,6 +1345,7 @@ impl DocView { // Signature previews own a separate renderer and image/background textures. self.signature_drag = Default::default(); self.text_background = Default::default(); + self.move_background = Default::default(); if !self.last_queue.is_empty() { pool.set_queue(Vec::new()); self.last_queue.clear(); @@ -1614,6 +1617,7 @@ impl DocView { let tex = ctx.load_texture(format!("page-{:?}-{page}", self.id), img, TextureOptions::LINEAR); self.pages.insert(page, PageTex { tag: r.request.tag, tex }); self.signature_drag.page_received(page); + self.move_background.page_received(page); self.waiting_since.remove(&page); if !self.stale_tiles.iter().any(|k| k.0 == page) { self.forms.unbaked.retain(|(_, p)| *p != page); @@ -2347,6 +2351,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { view.content.selected = None; } + view.move_background.prepare(ui.ctx(), doc, &view.content, scale); // A destination's position (bookmarks, links) refines `goto`: its point at the window's // top-left, or the centre of a /FitR rectangle at the window's centre. @@ -2546,6 +2551,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } view.text_background.paint(painter, &xf, i, view.line_editor.as_ref()); + if editing_content { + let page_tex = view.pages.get(&i).map(|p| p.tex.id()); + view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex); + } painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); // Comments: tools, selection, moving and resizing come before text selection. diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 5553c8501e..39355e6913 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -105,6 +105,128 @@ impl ContentView { } } +/// While an added item is moved, the page without it, rendered once per gesture: the item's own +/// pixels follow the pointer over it instead of staying put until release (#1488). +#[derive(Default)] +pub(crate) struct MoveBackground { + key: Option<(u64, usize, usize)>, + renderer: Option, + texture: Option, + tag: Option, + /// The item's display-space box when the gesture started. + from: Option<[f64; 4]>, + /// The applied move's box. The page raster on screen still has the item where it was until + /// its replacement arrives, so the preview stays up until then. + settling: Option<[f64; 4]>, +} + +impl MoveBackground { + pub fn pending(&self) -> bool { + self.renderer.is_some() && self.texture.is_none() + } + + pub fn prepare(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, cv: &ContentView, scale: f32) { + let key = match (cv.grab, cv.selected) { + (Some((gp, Grab::Move, _)), Some((sp, si))) if gp == sp => Some((doc.edit_generation(), sp, si)), + _ => None, + }; + if !(key.is_none() && self.settling.is_some()) && self.key != key { + *self = Self { key, ..Default::default() }; + if let Some((_, page, index)) = key { + self.renderer = doc.added_content_preview(page, index).ok().map(|p| p.renderer()); + self.from = on_page(&doc.added, page).into_iter().find(|(i, _)| *i == index).map(|(_, a)| a.content.rect()); + } + } + if self.settling.is_some() { + return; + } + let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return }; + let Some(p) = doc.info.pages.get(page) else { return }; + let max_side = ctx.input(|i| i.max_texture_side) as f32; + let scale = scale.min(crate::canvas::BASE_SIDE.min(max_side) / p.width.max(p.height).max(1.0)); + let tag = (scale * 1000.0) as u64; + if self.tag != Some(tag) { + renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); + self.tag = Some(tag); + } + if let Some(out) = renderer.try_recv() { + if out.error.is_some() { + // Without a background the item stays put while dragged, as before. + self.renderer = None; + return; + } + if out.request.tag == tag { + self.texture = Some(ctx.load_texture( + "added-content-move-background", + crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba), + egui::TextureOptions::LINEAR, + )); + } + } + if self.pending() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + /// The move was applied: keep the preview, now at the new generation, until the page is + /// rendered again. Any other edit ends it. + pub fn committed(&mut self, edit: &Edit, generation: u64) { + match edit { + Edit::UpdateContent { page, index, content } if self.texture.is_some() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => { + self.key = Some((generation, *page, *index)); + self.settling = Some(content.rect()); + } + _ => *self = Self::default(), + } + } + + pub fn page_received(&mut self, page: usize) { + if self.settling.is_some() && self.key.is_some_and(|(_, p, _)| p == page) { + *self = Self::default(); + } + } + + /// The page without the moving item, then the item's pixels taken from `page_tex` (the page + /// as rendered, item included where it was) at the dragged or applied position. + #[allow(clippy::too_many_arguments)] + pub fn paint( + &self, + ui: &egui::Ui, + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + cv: &ContentView, + page_tex: Option, + ) { + let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return }; + if kp != page { + return; + } + let r = screen_rect(xf, info, page, from); + let d = if let Some((_, _, start)) = cv.grab { + let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return }; + p - start + } else if let Some(to) = self.settling { + // A moved text box keeps its top edge (its height follows its lines). + screen_rect(xf, info, page, to).left_top() - r.left_top() + } else { + return; + }; + let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0); + let r = r.expand(1.0); + let mut mesh = egui::Mesh::with_texture(page_tex); + for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] { + let (u, v) = xf.screen_to_norm(c); + mesh.vertices.push(egui::epaint::Vertex { pos: c + d, uv: Pos2::new(u, v), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(mesh)); + } +} + /// Text style for new text (the panel's Format controls set it). pub fn default_style() -> AddedText { AddedText { size: 12.0, ..AddedText::default() } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6410898ce4..b3b02a38bc 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -43,6 +43,7 @@ impl PdfCraftApp { let info = &doc.info; let view = &mut self.views[i]; view.signature_drag.committed(&edit, doc.edit_generation()); + view.move_background.committed(&edit, doc.edit_generation()); if let Some(page) = comment_page(&edit).or_else(|| content_page(&edit)) { // Comment and content edits change one page: keep every other raster. view.page_changed(page); diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index 2435d77666..722144896b 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -1,16 +1,20 @@ //! Edit a PDF ▸ Add content in the real shell (egui_kittest): type text onto a page, move it, //! restyle it from the panel, add an image, delete with the keyboard. -use egui::Pos2; +use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; 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| { + harness_bytes(include_bytes!("data/form.pdf").to_vec()) +} + +fn harness_bytes(bytes: Vec) -> 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("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.open_bytes("form.pdf", None, bytes.clone()).unwrap(); app.set_option("zoom", "150").unwrap(); app }); @@ -231,3 +235,128 @@ fn typing_thai_text_keeps_marks_and_commits_to_page() { click(&mut h, p); assert_eq!(texts(&h), ["น้ำ กิ่ ผู้ใช้ Hello"]); } + +/// The screen rectangle of an added item's display-space box (y up, from the page's bottom). +fn item_on_screen(h: &Harness<'static, PdfCraftApp>, r: [f64; 4]) -> egui::Rect { + let s = h.state(); + let ph = s.session.get(s.views[0].id).unwrap().info.pages[0].height; + let page = s.views[0].page_screen_rect(0).expect("on screen"); + let k = page.width() / 300.0; + let p = |x: f64, y: f64| page.min + egui::vec2(x as f32 * k, (ph - y as f32) * k); + egui::Rect::from_two_pos(p(r[0], r[1]), p(r[2], r[3])) +} + +/// Dark pixels in `r`: glyph ink (the selection outline is blue, the page white). +fn ink(pixels: &image::RgbaImage, r: egui::Rect) -> usize { + let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32); + (y0..y1.min(pixels.height())) + .flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y))) + .filter(|(x, y)| pixels.get_pixel(*x, *y).0[..3].iter().all(|v| *v < 90)) + .count() +} + +/// Only the left part of the glyphs: kittest paints its cursor down and right of the pointer. +fn probe(r: egui::Rect) -> egui::Rect { + egui::Rect::from_min_size(r.min, egui::vec2(r.width() * 0.6, r.height())) +} + +/// Ink in the probes of the text's first and dragged places. +fn ink_at(h: &mut Harness<'static, PdfCraftApp>, from: egui::Rect, to: egui::Rect) -> (usize, usize) { + let pixels = h.render().unwrap(); + (ink(&pixels, probe(from)), ink(&pixels, probe(to))) +} + +/// Types text, finishes it, and drags it up without releasing, until the drag preview shows. +/// Returns the text's box, its screen rectangles before and after, and the pointer. +fn drag_added_text(h: &mut Harness<'static, PdfCraftApp>) -> ([f64; 4], egui::Rect, egui::Rect, Pos2) { + assert!(h.state_mut().execute("edit.text")); + h.run_steps(2); + let p = at(h, 40.0, 300.0); + click(h, p); + h.event(egui::Event::Text("MMMMMMMM".into())); + h.run_steps(2); + h.get_by_label("Done adding text").click(); + h.run_steps(3); + let rect = added(h)[0].content.rect(); + let from = item_on_screen(h, rect); + let mut shown = 0; + for _ in 0..100 { + h.run_steps(2); + shown = ink(&h.render().unwrap(), probe(from)); + if shown > 20 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(shown > 20, "the added text is on the page ({shown} dark pixels)"); + let grab = pos2(from.right() - 12.0, from.center().y); + let shift = egui::vec2(0.0, -150.0); + h.hover_at(grab); + h.run_steps(1); + h.drag_at(grab); + h.run_steps(1); + h.hover_at(grab + shift); + let to = from.translate(shift); + for _ in 0..100 { + h.run_steps(2); + let (old, new) = ink_at(h, from, to); + if old == 0 && new > 20 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + (rect, from, to, grab + shift) +} + +/// #1488: while a finished text box is dragged, its text follows the box instead of staying put +/// until the button is released. +#[test] +fn dragged_text_follows_the_pointer_before_release() { + let mut h = harness(); + let (rect, from, to, end) = drag_added_text(&mut h); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + h.render().unwrap().save(format!("{dir}/added-text-drag.png")).unwrap(); + } + let (old, new) = ink_at(&mut h, from, to); + assert!(new > 20, "the text is drawn where the box was dragged ({new} dark pixels)"); + assert_eq!(old, 0, "and no longer where it was"); + assert_eq!(added(&h)[0].content.rect(), rect, "nothing is applied before release"); + h.drop_at(end); + h.run_steps(3); + assert_ne!(added(&h)[0].content.rect(), rect, "releasing moves it"); +} + +/// After release the text stays in its new place while the page is re-rendered with the move, +/// instead of jumping back to where it was for those frames. +#[test] +fn released_text_does_not_jump_back_before_the_page_is_redrawn() { + // A page slow enough to render that the frames before its new raster can be looked at: many + // pale squares, too light to count as ink. + let mut content = String::from("0.9 g\n"); + for i in 0..60_000 { + content.push_str(&format!("{} {} 1 1 re f\n", i % 290, (i / 290) % 390)); + } + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + let mut h = harness_bytes(pdf.into_bytes()); + let (rect, from, to, end) = drag_added_text(&mut h); + h.drop_at(end); + // Frame by frame until the page has been re-rendered. The one frame that swaps in the new + // raster reads back blank here (kittest renders it after its old texture is freed). + let mut shown = Vec::new(); + while h.state().render_pending() || shown.is_empty() { + h.run_steps(1); + assert!(shown.len() < 500, "the page is re-rendered"); + let (old, new) = ink_at(&mut h, from, to); + assert_eq!(old, 0, "frame {} after release: the text is back where it was", shown.len() + 1); + shown.push(new); + } + assert!(shown.len() > 2, "frames before the new raster: {shown:?}"); + assert!(shown.iter().filter(|n| **n <= 20).count() <= 1, "the text stays where it was released: {shown:?}"); + assert_ne!(added(&h)[0].content.rect(), rect, "the move is applied"); + h.run_steps(2); + let (old, new) = ink_at(&mut h, from, to); + assert!(new > 20 && old == 0, "the redrawn page has the text in its new place ({old} old, {new} new)"); +} From a9745dd22d46e2c2245e2f315e6052b5122e2345 Mon Sep 17 00:00:00 2001 From: Jason Brashear <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:05:50 -0500 Subject: [PATCH 10/59] fix(organize): normalize rotations before adding so large quarter turns cannot overflow (#1512) * Contributors: credit webdevtodayjason as Jason Brashear * fix(organize): normalize rotations before adding so large quarter turns cannot overflow --- contributors/people.toml | 3 +++ crates/organize/src/lib.rs | 4 ++-- crates/organize/src/tests.rs | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 9bf6fd9fe6..aa8dd7a158 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -126,3 +126,6 @@ sync_github_name = true real_name = "Jeremy Yao" display_name = "Jeremy Yao" sync_github_name = false + +[people.webdevtodayjason] +display_name = "Jason Brashear" diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index a47adb429c..bb18939b52 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -158,11 +158,11 @@ pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Resu } let all = walk(doc)?; check(indices, all.len())?; - let delta = (degrees / 90) * 90; + let delta = degrees.rem_euclid(360); for &i in indices { let (page, inherited) = &all[i]; let current = doc.get(*page).as_dict().and_then(|d| d.int(b"Rotate")).or_else(|| inherited.int(b"Rotate")).unwrap_or(0); - let new = (current + delta).rem_euclid(360); + let new = (current.rem_euclid(360) + delta).rem_euclid(360); doc.update_dict(*page, |d| d.set(b"Rotate".to_vec(), Object::Int(new)))?; } Ok(()) diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index b4aae6b576..2ae38fb863 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -28,6 +28,20 @@ fn rotate_rejects_angles_that_are_not_multiples_of_90() { assert_eq!(page_rotation(&doc, 0).unwrap(), 0); } +#[test] +fn rotate_handles_large_quarter_turns() { + let large = i64::MAX - i64::MAX % 90; + for (current, degrees, expected) in [(90, large, 90), (-90, -large, 270), (large, 90, 90), (-large, -90, 270), (180, large - 90, 90)] { + for inherited in [false, true] { + let mut doc = open(fixture()); + let target = if inherited { pages_root(&doc).unwrap() } else { pages(&doc).unwrap()[0].obj }; + doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Int(current))).unwrap(); + rotate_pages(&mut doc, &[0], degrees).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), expected); + } + } +} + /// A 3-page document with a nested page tree. MediaBox and Rotate are inherited from the root, /// Resources from an intermediate node; each page's content says which page it is. fn fixture() -> Vec { From 6803ee894ba4676855e0a804b9fe4da8272de80c Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:05:58 +0200 Subject: [PATCH 11/59] Allow mouse-wheel scrolling during text selection (#1514) * Allow mouse-wheel scrolling during text selection * Keep wheel-scrolling a selection drag over the page gap and the next page (#721) Move the wheel handling out of the per-page drag branch to after the page loop, gated on the pointer being over the document area (like #759), so it no longer stops once the pointer leaves the selection's page. Co-authored-by: Waleed --------- Co-authored-by: Waleed --- crates/ui-egui/src/canvas.rs | 26 +++ crates/ui-egui/tests/ui_group/text_select.rs | 87 +++++++++- crates/ui-egui/tests/viewer.rs | 174 +++++++++++++++++++ 3 files changed, 285 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 82c072350b..363c3c837c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -354,6 +354,9 @@ pub struct DocView { pub(crate) text_failed: HashSet, pub find: Option, selection: Option, + /// The drag in progress started a text selection: the wheel scrolls the page during it + /// (#721), which egui's scroll area refuses while anything is dragged. + selection_drag: bool, /// Clicks in the current run on the text layer: two select a word, three the line, four the page. clicks: u32, last_press_time: f64, @@ -613,6 +616,7 @@ impl DocView { text_failed: HashSet::new(), find: None, selection: None, + selection_drag: false, clicks: 0, last_press_time: 0.0, last_press_pos: None, @@ -2382,6 +2386,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + // A drag lasts only while egui reports it, so the mark can't outlive one. + if !resp.dragged_by(egui::PointerButton::Primary) { + view.selection_drag = false; + } // The Hand tool pans: the content widget takes every drag, so scroll by its delta. if hand { if resp.dragged() { @@ -2798,6 +2806,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } if resp.drag_started() && press_here && !over_link { + view.selection_drag = true; let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p); let (ox, oy) = xf.screen_to_view(origin); if view.clicks < 2 { @@ -2988,6 +2997,23 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } + // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag + // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the + // pointer leaves the selection's page for the gap or the next page; over the whole + // document area, like #759. A Line/Page event clears precise_scroll_until; a Point + // gesture's eased tail after its suppression deadline is not picked up. + if view.selection_drag + && navigation_enabled + && unobstructed + && !single_paging + && view.precise_scroll_until == 0.0 + && ui.rect_contains_pointer(avail) + { + let delta = ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)); + if delta != Vec2::ZERO { + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + } // The page navigated to stays current while no page shows more of itself, so the next // page step starts from it rather than from a page further down the screen (#188). // It also stays current while it is wholly on screen: a short page (a cheque) gone to diff --git a/crates/ui-egui/tests/ui_group/text_select.rs b/crates/ui-egui/tests/ui_group/text_select.rs index d6f970de80..a98b2f8ed0 100644 --- a/crates/ui-egui/tests/ui_group/text_select.rs +++ b/crates/ui-egui/tests/ui_group/text_select.rs @@ -1,7 +1,7 @@ //! Issue #295: quick clicks on text widen the selection, as in Acrobat: two clicks select a //! word, three the line, four all the text on the page. -use egui::{Event, Modifiers, PointerButton, Pos2}; +use egui::{Event, Modifiers, MouseWheelUnit, PointerButton, Pos2, TouchPhase}; use egui_kittest::Harness; use pdfcraft_ui_egui::PdfCraftApp; @@ -27,10 +27,15 @@ trailer << /Root 1 0 R >> /// The page on screen and the pointer over the "u" of "quick". Frames are 1/60 s: a click takes /// two frames (press, release), and egui counts a double-click only within 0.3 s. fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> (Harness<'static, PdfCraftApp>, Pos2) { + harness_with(two_lines(), setup) +} + +/// `harness` with another document; the pointer ends over glyph 5 of its first page. +fn harness_with(pdf: Vec, 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.open_bytes("lines.pdf", None, pdf).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("author", "Tester").unwrap(); setup(&mut app); @@ -184,3 +189,81 @@ fn multiclick_selects_on_pointer_press_before_release() { h.run_steps(1); assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The quick brown fox"), "line stays selected on release"); } + +/// `n` pages of 300×200, each ending with one line of Helvetica text near its bottom edge. +fn bottom_lines(n: usize) -> Vec { + let mut objects = String::new(); + let mut kids = Vec::new(); + for k in 0..n { + let (page, contents) = (4 + 2 * k, 5 + 2 * k); + kids.push(format!("{page} 0 R")); + let content = format!("BT /F1 14 Tf 20 20 Td (Page {} ends with this line) Tj ET", k + 1); + objects.push_str(&format!( + "{page} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents {contents} 0 R /Resources << /Font << /F1 3 0 R >> >> >> endobj\n\ + {contents} 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", + content.len() + )); + } + format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [{}] /Count {n} >> endobj\n\ + 3 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n{objects}trailer << /Root 1 0 R >>\n%%EOF", + kids.join(" ") + ) + .into_bytes() +} + +/// Press at `from` and move to `to`, keeping the button down. +fn press_and_move(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + (to - from) * (k as f32 / 4.0)); + h.run_steps(1); + } +} + +/// One wheel event, with time to scroll (egui spreads a wheel line over a few frames). +fn wheel(h: &mut Harness<'static, PdfCraftApp>, unit: MouseWheelUnit, dy: f32) { + h.event(Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase: TouchPhase::Move, modifiers: Modifiers::NONE }); + h.run_steps(10); +} + +/// The top edge of `page` on screen. +fn page_top(h: &Harness<'static, PdfCraftApp>, page: usize) -> f32 { + h.state().views[0].page_screen_rect(page).expect("page on screen").min.y +} + +/// Issue #721: selecting near the bottom of a page with the button held, the wheel keeps +/// scrolling while the pointer crosses the gap onto the next page. The selection stays on its +/// own page (a selection can't span pages yet). +#[test] +fn a_selection_drag_keeps_scrolling_over_the_gap_and_the_next_page() { + let (mut h, _) = harness_with(bottom_lines(3), |app| { + app.set_option("layout", "continuous").unwrap(); + app.set_option("zoom", "200").unwrap(); + }); + let glyph = |h: &Harness<'static, PdfCraftApp>, g: usize| h.state().views[0].glyph_screen_pos(0, g).expect("glyph on screen"); + // "ends" (glyphs 7..=10) in "Page 1 ends with this line". + let (from, to) = (glyph(&h, 7), glyph(&h, 10)); + press_and_move(&mut h, from, to); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("ends")); + let (mut over_gap, mut over_next) = (false, false); + for _ in 0..100 { + let before = page_top(&h, 0); + wheel(&mut h, MouseWheelUnit::Line, -0.1); + assert!(page_top(&h, 0) < before - 0.5, "the wheel scrolls during the drag (over the gap yet: {over_gap})"); + let view = &h.state().views[0]; + let on = |page| view.page_screen_rect(page).is_some_and(|r| r.contains(to)); + over_gap |= !on(0) && !on(1); + over_next = on(1); + if over_next { + break; + } + } + assert!(over_gap && over_next, "the pointer crossed the gap ({over_gap}) onto the next page ({over_next})"); + let held = h.state().views[0].selected_text().expect("still selected"); + assert!(held.starts_with("ends") && "Page 1 ends with this line".contains(held.as_str()), "the selection stays on page 1: {held:?}"); + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().views[0].selected_text(), Some(held), "releasing keeps the selection"); +} diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 35a82c59ec..b5b1668bf6 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -1044,3 +1044,177 @@ fn a_picture_inside_form_artwork_under_text_is_selected_and_copied() { let png = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png).unwrap().into_rgba8(); assert_eq!(png.into_raw(), vec![255, 0, 0, 255, 0, 0, 255, 255]); } +#[test] +fn mouse_wheel_scrolls_while_selecting_text() { + let mut h = harness(); + h.state_mut().set_option("zoom", "200").unwrap(); + h.state_mut().set_option("left", "closed").unwrap(); + h.state_mut().set_option("panel", "none").unwrap(); + h.run_steps(8); + let view = h.state().active.unwrap(); + let page = h.state().views[view].page_screen_rect(0).unwrap(); + let start = egui::pos2(page.left() + page.width() * 0.11, page.top() + page.height() * 0.48); + let end = start + egui::vec2(100.0, 0.0); + h.hover_at(start); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.hover_at(end); + h.run_steps(2); + assert!(h.state().views[view].selected_text().is_some_and(|s| !s.is_empty()), "a text selection is active"); + assert!(h.ctx.dragged_id().is_some(), "the primary drag is still held"); + let before = h.state().views[view].page_screen_rect(0).unwrap().top(); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -3.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(20); + let after = h.state().views[view].page_screen_rect(0).unwrap().top(); + assert!(after < before - 1.0, "wheel should move the page during selection: {before} -> {after}"); + assert!(h.state().views[view].selected_text().is_some_and(|s| !s.is_empty())); + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); +} +fn selection_wheel_harness() -> (Harness<'static, PdfCraftApp>, usize, egui::Pos2) { + let mut h = harness(); + h.state_mut().set_option("zoom", "200").unwrap(); + h.state_mut().set_option("left", "closed").unwrap(); + h.state_mut().set_option("panel", "none").unwrap(); + h.run_steps(8); + let view = h.state().active.unwrap(); + let page = h.state().views[view].page_screen_rect(0).unwrap(); + let start = egui::pos2(page.left() + page.width() * 0.11, page.top() + page.height() * 0.48); + let end = start + egui::vec2(100.0, 0.0); + h.hover_at(start); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.hover_at(end); + h.run_steps(2); + assert!(h.state().views[view].selected_text().is_some_and(|s| !s.is_empty()), "a text selection is active"); + assert!(h.ctx.dragged_id().is_some(), "the primary drag is still held"); + + (h, view, end) +} + +fn selection_wheel_delta(unit: egui::MouseWheelUnit, amount: f32, held: bool, animated: bool) -> f32 { + let (mut h, view, end) = selection_wheel_harness(); + h.state_mut().animation_effects = + if animated { pdfcraft_ui_egui::motion::AnimationEffects::On } else { pdfcraft_ui_egui::motion::AnimationEffects::Off }; + if !held { + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(2); + let before = h.state().views[view].page_screen_rect(0).unwrap().top(); + h.event(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, -amount), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }); + h.run_steps(40); + before - h.state().views[view].page_screen_rect(0).unwrap().top() +} + +#[test] +fn selection_wheel_preserves_normal_distance_and_reduced_motion() { + for animated in [true, false] { + let normal = selection_wheel_delta(egui::MouseWheelUnit::Line, 1.0, false, animated); + let held = selection_wheel_delta(egui::MouseWheelUnit::Line, 1.0, true, animated); + assert!(normal > 1.0); + assert!((normal - held).abs() < 1.0, "animated={animated}: released={normal}, held={held}"); + } + let on = selection_wheel_delta(egui::MouseWheelUnit::Line, 1.0, true, true); + let off = selection_wheel_delta(egui::MouseWheelUnit::Line, 1.0, true, false); + assert!((on - off).abs() < 1.0, "on={on}, off={off}"); +} + +#[test] +fn selection_point_scroll_is_not_applied_twice() { + for animated in [true, false] { + let normal = selection_wheel_delta(egui::MouseWheelUnit::Point, 30.0, false, animated); + let held = selection_wheel_delta(egui::MouseWheelUnit::Point, 30.0, true, animated); + eprintln!("Point animated={animated}: released={normal}, held={held}"); + assert!(normal > 1.0, "point wheel must scroll: {normal}"); + assert!((held - 30.0).abs() < 1.0, "animated={animated}: held precision motion={held}, normal={normal}"); + } +} + +#[test] +fn selection_control_wheel_still_zooms() { + for modifiers in [egui::Modifiers::CTRL, egui::Modifiers::COMMAND] { + let (mut h, view, _) = selection_wheel_harness(); + let before = h.state().views[view].page_screen_rect(0).unwrap().width(); + h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers }); + h.run_steps(40); + let after = h.state().views[view].page_screen_rect(0).unwrap().width(); + assert!(after > before + 1.0, "control wheel should zoom: {before} -> {after}"); + } +} + +#[test] +fn selection_wheel_outside_document_does_not_scroll() { + let (mut h, view, _) = selection_wheel_harness(); + h.hover_at(egui::pos2(1398.0, 2.0)); + h.run_steps(2); + let before = h.state().views[view].page_screen_rect(0).unwrap().top(); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(40); + let after = h.state().views[view].page_screen_rect(0).unwrap().top(); + assert!((after - before).abs() < 1.0, "outside wheel moved document: {before} -> {after}"); +} + +#[test] +fn selection_page_wheel_preserves_normal_distance() { + for animated in [true, false] { + let normal = selection_wheel_delta(egui::MouseWheelUnit::Page, 0.1, false, animated); + let held = selection_wheel_delta(egui::MouseWheelUnit::Page, 0.1, true, animated); + assert!(normal > 1.0); + assert!((normal - held).abs() < 1.0, "animated={animated}: released={normal}, held={held}"); + } +} + +#[test] +fn selection_wheel_does_not_scroll_behind_preferences() { + let (mut h, view, _) = selection_wheel_harness(); + h.state_mut().set_option("dialog", "preferences").unwrap(); + h.run_steps(3); + let before = h.state().views[view].page_screen_rect(0).unwrap().top(); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(40); + let after = h.state().views[view].page_screen_rect(0).unwrap().top(); + assert!((after - before).abs() < 1.0, "modal wheel moved document: {before} -> {after}"); +} + +#[test] +fn selection_mouse_wheel_resumes_after_point_scrolling() { + for delay in [1, 16, 40] { + let (mut h, view, _) = selection_wheel_harness(); + let start = h.state().views[view].page_screen_rect(0).unwrap().top(); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -30.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(delay); + let before = h.state().views[view].page_screen_rect(0).unwrap().top(); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(40); + let after = h.state().views[view].page_screen_rect(0).unwrap().top(); + eprintln!("Point-to-Line delay={delay}: point={}, line={}", start - before, before - after); + assert!(before - after > 50.0, "Line input must resume after Point: {before} -> {after}"); + } +} From 80eff6775ffc6db825a21dcdddb9734cdc34f698 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:07:15 +0200 Subject: [PATCH 12/59] fix(audit): a rectangle drawn as an m/l path is a cover like re (#1521) * fix(audit): a rectangle drawn as an m/l path is a cover like re (fixes #1510) * docs(parity): a rectangle drawn as an m/l path counts as a cover (#1510) --- crates/audit/README.md | 5 +- crates/audit/src/cover.rs | 114 +++++++++++++++++++++++++++++------ crates/audit/src/tests.rs | 45 ++++++++++++++ parity/acrobat-features.toml | 4 +- 4 files changed, 147 insertions(+), 21 deletions(-) diff --git a/crates/audit/README.md b/crates/audit/README.md index e13bb30b0d..c8a6a3249f 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -18,8 +18,9 @@ shading and highlights don't either: they're painted before the text, or light a Limits, by design and documented rather than hidden: -- only `re` rectangles closed by a fill count as covers (not arbitrary paths, shadings or - patterns), and a translucent one (`/ca` below 1) is not flagged; +- only filled rectangles count as covers, drawn with `re` or as a single `m`/`l` subpath whose + corners form an axis-aligned rectangle (not other paths, shadings or patterns), and a + translucent one (`/ca` below 1) is not flagged; - covers inside form XObjects are found, but only text drawn directly in the page content is checked under them; - annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign. diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index f7737da780..be8afe0a85 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -1,10 +1,11 @@ //! Walk page content streams and collect the opaque filled rectangles they draw. //! //! This is the geometric half of the faux-redaction audit: a rectangle filled with an opaque -//! color (`re` + `f`) is the classic way a "redaction" is faked. Graphics state tracked here is -//! deliberately small: the CTM stack (`q`/`Q`/`cm`), the non-stroking color (`g`/`rg`/`k`) and -//! the non-stroking alpha from ExtGState (`gs` reading `/ca`). Form XObjects are followed -//! read-only with a depth cap. Anything fancier (arbitrary filled paths, shadings, patterns) is +//! color (`re` + `f`) is the classic way a "redaction" is faked. A single closed `m`/`l` subpath +//! whose four corners form an axis-aligned rectangle in user space is the same shape and counts +//! too (#1510). Graphics state tracked here is deliberately small: the CTM stack (`q`/`Q`/`cm`), +//! the non-stroking color (`g`/`rg`/`k`) and the non-stroking alpha from ExtGState (`gs` reading +//! `/ca`). Form XObjects are followed read-only with a depth cap. Anything fancier (other filled paths, shadings, patterns) is //! out of scope and documented as such. //! //! Paint order: a page's content streams are one stream in pieces (ISO 32000-2 §7.8.2), so they @@ -45,6 +46,58 @@ struct Gs { alpha: f64, } +/// The path under construction, as far as the audit cares about it. +enum Path { + Empty, + /// A `re` rectangle, in user space. + Rect([f64; 4]), + /// One `m`/`l` subpath, corners already mapped to user space; `true` once closed by `h`. + Lines(Vec<[f64; 2]>, bool), + /// Anything else: curves, a second subpath, a CTM change mid-path. + Other, +} + +impl Path { + /// The rectangle a fill of this path paints, if the path is one. + fn rect(self) -> Option<[f64; 4]> { + match self { + Path::Rect(r) => Some(r), + Path::Lines(pts, _) => lines_rect(pts), + Path::Empty | Path::Other => None, + } + } +} + +/// The bounding box of a closed polygon that is an axis-aligned rectangle, or `None`. A repeated +/// start point is dropped first; the remaining four corners must alternate horizontal and +/// vertical edges. +fn lines_rect(mut pts: Vec<[f64; 2]>) -> Option<[f64; 4]> { + // CTM products leave rounding noise, so corners compare with a tolerance (in points). + const EPS: f64 = 1e-6; + let same = |a: f64, b: f64| (a - b).abs() <= EPS; + if pts.len() == 5 && same(pts[0][0], pts[4][0]) && same(pts[0][1], pts[4][1]) { + pts.pop(); + } + let [a, b, c, d] = <[[f64; 2]; 4]>::try_from(pts).ok()?; + let fits = |first_horizontal: bool| { + [(a, b), (b, c), (c, d), (d, a)] + .iter() + .enumerate() + .all(|(i, (p, q))| if (i % 2 == 0) == first_horizontal { same(p[1], q[1]) } else { same(p[0], q[0]) }) + }; + if !(fits(true) || fits(false)) { + return None; + } + let (xs, ys) = ([a[0], b[0], c[0], d[0]], [a[1], b[1], c[1], d[1]]); + let r = [ + xs.into_iter().fold(f64::INFINITY, f64::min), + ys.into_iter().fold(f64::INFINITY, f64::min), + xs.into_iter().fold(f64::NEG_INFINITY, f64::max), + ys.into_iter().fold(f64::NEG_INFINITY, f64::max), + ]; + (r[2] - r[0] > EPS && r[3] - r[1] > EPS).then_some(r) +} + impl Default for Gs { fn default() -> Self { Gs { ctm: Matrix::IDENTITY, fill: [0.0, 0.0, 0.0], alpha: 1.0 } @@ -79,8 +132,7 @@ impl<'a> Walker<'a> { depth: usize, at_do: Option<(usize, usize)>, ) -> Result<(), AuditError> { - // A pending `re` rectangle, in the user space active when it was built. - let mut pending: Option<[f64; 4]> = None; + let mut path = Path::Empty; for (i, op) in ops.iter().enumerate() { self.bump()?; // Paint position of this operator: its own `(piece, joined index)` at page level, @@ -95,7 +147,7 @@ impl<'a> Walker<'a> { if let Some(g) = stack.pop() { *gs = g; } - pending = None; + path = Path::Empty; } b"cm" => { let before = gs.ctm; @@ -105,9 +157,9 @@ impl<'a> Walker<'a> { gs.ctm = m.then(&gs.ctm); } // A `cm` that leaves the CTM unchanged (an identity matrix) cannot move the - // pending rectangle, so keep it for the fill (#1333). - if gs.ctm != before { - pending = None; + // pending path, so keep it for the fill (#1333). + if gs.ctm != before && !matches!(path, Path::Empty) { + path = Path::Other; } } b"g" => { @@ -138,7 +190,7 @@ impl<'a> Walker<'a> { } } b"re" => { - pending = op.nums::<4>().and_then(|[x, y, w, h]| { + let rect = op.nums::<4>().and_then(|[x, y, w, h]| { if [x, y, w, h].iter().all(|v| v.is_finite()) && w != 0.0 && h != 0.0 { let r = gs.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]); (r[2] > r[0] && r[3] > r[1]).then_some(r) @@ -146,18 +198,39 @@ impl<'a> Walker<'a> { None } }); + path = rect.map_or(Path::Other, Path::Rect); + } + b"m" => { + path = match (path, user_point(gs, op)) { + (Path::Empty, Some(p)) => Path::Lines(vec![p], false), + _ => Path::Other, + }; + } + b"l" => { + path = match (path, user_point(gs, op)) { + // Past a repeated start point it can no longer be a rectangle. + (Path::Lines(mut pts, false), Some(p)) if pts.len() < 5 => { + pts.push(p); + Path::Lines(pts, false) + } + _ => Path::Other, + }; + } + b"h" => { + path = match path { + Path::Lines(pts, _) => Path::Lines(pts, true), + _ => Path::Other, + }; } b"f" | b"F" | b"f*" | b"B" | b"B*" => { - if let Some(r) = pending.take() + if let Some(r) = std::mem::replace(&mut path, Path::Empty).rect() && gs.alpha >= 1.0 { self.out.push(FilledRect { rect: r, rgb: gs.fill, pos }); } - // Any other path construction clears the pending rectangle. - } - b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"S" | b"s" | b"n" | b"W" | b"W*" => { - pending = None; } + b"S" | b"s" | b"n" => path = Path::Empty, + b"c" | b"v" | b"y" | b"W" | b"W*" => path = Path::Other, b"Do" => { if let Some(name) = op.name(0) { if depth >= MAX_DEPTH { @@ -165,7 +238,7 @@ impl<'a> Walker<'a> { } self.walk_xobject(name, gs, resources, depth, pos)?; } - pending = None; + path = Path::Empty; } _ => {} } @@ -206,6 +279,13 @@ impl<'a> Walker<'a> { } } +/// The `x y` operands of `m`/`l`, mapped to user space. +fn user_point(gs: &Gs, op: &Op) -> Option<[f64; 2]> { + let [x, y] = op.nums::<2>().filter(|v| v.iter().all(|n| n.is_finite()))?; + let (x, y) = gs.ctm.apply(x, y); + Some([x, y]) +} + /// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque. fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { let alpha = resources diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 102f3fb1f0..be98d72842 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -227,3 +227,48 @@ fn self_drawing_form_fails_the_audit() { let result = audit_redactions(&open(b.build())); assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}"); } + +#[test] +fn rectangle_drawn_as_a_path_covers_text_like_re() { + // The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or + // only by the fill itself), or in a scaled user space: all are faux redactions (#1510). + let covers: [&[u8]; 5] = [ + b"0 g\n100 688 220 32 re f\n", + b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h f\n", + b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l 100 688 l h f\n", + b"0 g\n100 688 m 100 720 l 320 720 l 320 688 l f\n", + b"0 g\nq 2 0 0 2 0 0 cm 50 344 m 160 344 l 160 360 l 50 360 l h f Q\n", + ]; + for cover in covers { + let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec(); + content.extend_from_slice(cover); + let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{:?}: {findings:?}", String::from_utf8_lossy(cover)); + assert_eq!(findings[0].kind, FindingKind::CoveredText); + assert_eq!(findings[0].rect, [100.0, 688.0, 320.0, 720.0]); + assert_eq!(findings[0].covered_text, "SECRET DATA 123"); + } +} + +#[test] +fn non_rectangular_path_is_not_a_cover() { + // Each path's bounding box covers the text, but the shape itself does not hide it all. + let covers: [&[u8]; 5] = [ + // A triangle. + b"0 g\n100 688 m 320 688 l 320 720 l h f\n", + // A triangle, then a line from its start point after `h`: not a fourth corner. + b"0 g\n100 688 m 320 688 l 320 720 l h 100 720 l f\n", + // A trapezoid: four corners, one slanted side. + b"0 g\n100 688 m 320 688 l 320 720 l 140 720 l h f\n", + // A rectangle rotated 5 degrees about the text by the CTM. + b"0 g\nq 0.9962 0.0872 -0.0872 0.9962 62.15 -15.63 cm 100 688 m 320 688 l 320 720 l 100 720 l h f Q\n", + // Two subpaths filled together: even-odd leaves the inner one as a hole. + b"0 g\n90 680 m 330 680 l 330 730 l 90 730 l h 100 688 m 320 688 l 320 720 l 100 720 l h f*\n", + ]; + for cover in covers { + let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec(); + content.extend_from_slice(cover); + let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run"); + assert!(findings.is_empty(), "{:?}: {findings:?}", String::from_utf8_lossy(cover)); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7d8e8703c5..f874d52f88 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5788,8 +5788,8 @@ milestone = "M8" status = "partial" commands = [] tools = ["doc_audit_redactions"] -evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools"] -notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)." +evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools"] +notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)." [[feature]] id = "protect.remove-hidden-on-close" From a4126d34e7e6da8d7a7d65105e92705f491ab0ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mahmud=20Emre=20BA=C5=9EPEHL=C4=B0VAN?= <93977436+Mahmud-Baspehlivan@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:07:22 +0300 Subject: [PATCH 13/59] docs: merge the duplicated Auto paragraph in localization.md (#1525) Two language additions each edited the "Auto follows the system language" paragraph, and both versions ended up in the file: one mentioned Turkish, the other Slovenian, and neither mentioned both. Keep one paragraph that lists both. Co-authored-by: Claude Opus 5.5 --- docs/localization.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/localization.md b/docs/localization.md index 5f27bdb78e..05240cafbf 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -4,8 +4,7 @@ Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma else Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable. -**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. -**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. +**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. The control channel exposes the setting through `ui.set`: From 13fbb2f6fbf2fe60a917512ab9df169335741383 Mon Sep 17 00:00:00 2001 From: bmh129 Date: Mon, 12 Oct 2026 23:07:32 -0500 Subject: [PATCH 14/59] M0: label local builds in --version, About and the package release (#1529) A build compiled with PDFCRAFT_BUILD_LABEL=