diff --git a/AGENTS.md b/AGENTS.md index 6126d4887f..297105c4c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,6 +175,7 @@ PdfCraft opens files from strangers, and people trust it with their work. A malf - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state. `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim`, `crates/ui-egui/src/canvas/page_cursor.rs`) and never set it themselves. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. - **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. Modules never read the router; they are told. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. +- **Page keys.** Page code (everything under `canvas/` and `tools/`, `app/keys.rs`, `app/clipboard.rs` and the Pages panel's `panels/pages.rs`) reads keys and clipboard events through `PageKeys` (`crates/ui-egui/src/canvas/page_keys.rs`), which has none while something is over the pages (a dialog, the palette, a popup, a menu: `PdfCraftApp::over_pages`), never egui's raw key state. The functions `NOT_PAGE_KEYS` lists, each with why, read raw: a window's or a field's own keys, which `PageKeys` would hide, and the few page reads it can't make (chords that wait for the menu bar instead, Escape ending what the pages hold, any key stopping a glide, the clipboard's hand-off to text fields). `page_pointer::tests::page_modules_read_keys_only_through_page_keys` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Fonts** are the optional build input `CRAFT_FONTS_DIR`. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty. - **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check. New workspace crates include `workspace = true` under `[lints]`. Scope `cargo clippy -p --all-targets -- -D warnings` and `cargo test -p ` to the crates you touched; the workspace sweep is CI's job. diff --git a/Cargo.lock b/Cargo.lock index fafd037d33..781255619d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,7 +1243,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=7ee35a9fbd47af572d55fd63dbf0333d1f00da17#7ee35a9fbd47af572d55fd63dbf0333d1f00da17" +source = "git+https://github.com/storytold/craft-libs?rev=0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d#0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d" dependencies = [ "craft-winprint-gdi", "log", @@ -4416,6 +4416,7 @@ dependencies = [ "sha2 0.11.1", "stringprep", "thiserror 2.0.21", + "zeroize", ] [[package]] @@ -4656,10 +4657,12 @@ name = "pdfcraft-print" version = "0.18.1" dependencies = [ "craft-print", + "log", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", "pdfcraft-render", + "skrifa 0.44.0", "thiserror 2.0.21", ] @@ -4747,6 +4750,7 @@ dependencies = [ "sha2 0.11.1", "sha3", "thiserror 2.0.21", + "zeroize", ] [[package]] @@ -4808,6 +4812,7 @@ dependencies = [ "web-time", "winsafe", "xattr", + "zeroize", ] [[package]] @@ -8310,7 +8315,7 @@ dependencies = [ [[package]] name = "winprint" version = "0.2.1" -source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4" +source = "git+https://github.com/storytold/winprint.rs?rev=3c978dfcf3d460577fabd87aeea7a84ffdcfb396#3c978dfcf3d460577fabd87aeea7a84ffdcfb396" dependencies = [ "flate2", "fmt-derive", @@ -8325,6 +8330,7 @@ dependencies = [ "windows", "windows-core", "windows-implement", + "windows-numerics", "xml", ] diff --git a/Cargo.toml b/Cargo.toml index b0f3411624..ab8b964ce8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1 # the commit in `craft-i18n.rev`). Already in the tree through craft-ui. craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "7ee35a9fbd47af572d55fd63dbf0333d1f00da17" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. @@ -84,6 +84,7 @@ hayro-jbig2 = "0.3" # foundation thiserror = "2" +zeroize = "1" # Linear-time matching (no backtracking) for caller-supplied redaction patterns. `perf-dfa` gives # the lazy-DFA cache that `RegexBuilder::dfa_size_limit` bounds (the limit is inert without it). regex = { version = "1", default-features = false, features = ["std", "unicode", "perf-dfa"] } diff --git a/ROADMAP.md b/ROADMAP.md index e566ade4d2..273ff54cbf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged) +> > **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged) > **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged) @@ -28,6 +30,7 @@ > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main while retaining display-only CJK fallback and upstream Japanese font selection; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged) +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (M7, #2750: Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged.) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (M1, #1143: repaired aliases survive preflight, snapshots and normalized saves; retained materialized revisions report generation disagreements; estimates unchanged) @@ -238,6 +241,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-11-09 (M10, #5130):** Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged. + - **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886. - **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable XFDF appearance is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft; an FDF past the import budget is refused whole. A redaction mark is always drawn by PdfCraft. @@ -290,6 +295,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged. - **2026-10-27 (M10, `ocr.ocr-web`, rebase fix):** The web build loads OCR models only when their SHA-256 matches `ATTRIBUTION.toml`. With ONNX loading now on, rten's parser spins forever on some bytes that aren't a model (a corrupt or wrong download), so a bad file now fails at once instead of hanging a worker. +- **2026-10-27 (M3, #3425):** A file named with open parameters, as help systems start a PDF reader (`manual.pdf#nameddest=Intro`, `#page=12` or `#Intro`), opens at that destination instead of failing to read `manual.pdf#…`: at launch, when another launch hands it over, and in its tab when it is already open. Other parameters are ignored. The app and `link_open` now also apply a GoToR link's named destination, looked up in the target's /Names /Dests or catalog /Dests, instead of warning that it wasn't applied; the app looks names up off the UI thread. `doc_open` reports the destination of a path with open parameters. Estimates unchanged. - **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence. - **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged. @@ -298,6 +304,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged. - **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged. - **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged. +- **2026-10-25 (M7, #2750):** Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged. - **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. @@ -330,6 +337,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Estimates unchanged. - **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. - **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. +- **2026-10-22 (M10, #3132):** Windows printing no longer swaps the letters of embedded TrueType subsets. A subset renumbers its glyphs (ReportLab's Arial space is glyph 11, the installed Arial's is 3), and the Windows print path drew those numbers with the installed font of the same name: `Revisión` printed as `Wj{nxn`, the bold `z` as `Ä`, on Brother queues and the Adobe PDF printer alike. The copy sent to the Windows spooler now names every embedded TrueType/OpenType program `PdfCraftPrint`, in its `name` table and in the PDF; glyphs, encodings and `/ToUnicode` are untouched and a synthetic test renders the page identically before and after. Not yet checked on a physical Windows printer. Estimates unchanged. - **2026-10-24 (M5, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged. - **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged. - **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged. @@ -829,6 +837,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-11-09 | patch | M10 #5130: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Estimates unchanged. | | 2026-11-08 | patch | M10 #6514/#636: retain main's public GDI Git revision (64e7afd4) to preserve downstream Rust type identity, update the internal PDF loader independently, and refresh the application dependency/lockfile. New-graph CI pending; estimates unchanged. | | 2026-11-08 | patch | M10 #6514: refresh the craft-print and winprint/GDI pins after #605 merged; retain the Direct2D crash fix and stream-test cleanup repairs, record single-page HUAWEI output with its diagnostic limits, and rerun CI for the new dependency graph. Estimates unchanged. | | 2026-11-07 | patch | M10 #6514: load Windows PDF print jobs on demand through a read-only file stream, avoiding StorageFile access denial, whole-file buffering and per-chunk async waits; approved dependencies retain the #5006 crash fix. Regression tests pass; physical output remains unqualified; estimates unchanged. | diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index cb3365a4fe..b5effbc118 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -434,7 +434,10 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> { for warning in &p.warnings { match warning { pdfcraft_render::RenderWarning::ContentTruncated => { - let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped"); + let _ = writeln!( + std::io::stderr().lock(), + "warning: page {page}: content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped" + ); } pdfcraft_render::RenderWarning::ColourFallback => { let _ = diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 837a803e22..797e14e52f 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -475,6 +475,7 @@ fn app_creator<'a>( } app.set_startup_update_check(true); app.printer_source = Some(pdfcraft_ui_egui::system_printers()); + app.two_sided_source = Some(pdfcraft_ui_egui::system_two_sided()); app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); #[cfg(target_os = "macos")] { diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs index 60f5d1d493..f60e74dd2f 100644 --- a/apps/pdfcraft/src/single_instance.rs +++ b/apps/pdfcraft/src/single_instance.rs @@ -152,9 +152,20 @@ pub fn parse_request(line: &str) -> Option<(Vec, bool)> { } /// File arguments made absolute against this launch's working folder, which the running app -/// doesn't share. A path that can't be made absolute is sent as it is. +/// doesn't share. A path that can't be made absolute is sent as it is. Of a file named with open +/// parameters (`manual.pdf#nameddest=Intro`, #3425), only the file is made absolute and the +/// parameters are sent as given: as part of the path, `//` and `/./` in a destination name were +/// folded, and on Windows `/` became `\`, `..` took the file name with it and a trailing dot went. pub fn absolute(paths: &[String]) -> Vec { - paths.iter().map(|p| std::path::absolute(p).map_or_else(|_| p.clone(), |a| a.to_string_lossy().into_owned())).collect() + let made = |p: &str| std::path::absolute(p).map_or_else(|_| p.to_string(), |a| a.to_string_lossy().into_owned()); + paths + .iter() + .map(|p| match pdfcraft_engine::open_parameters::split(p, |f| std::path::Path::new(f).is_file()) { + // `file` is what comes before one of `p`'s `#`, so the rest starts at that `#`. + Some((file, _)) => format!("{}{}", made(&file), p.get(file.len()..).unwrap_or_default()), + None => made(p), + }) + .collect() } /// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false @@ -416,6 +427,26 @@ mod tests { assert_eq!(absolute(std::slice::from_ref(&already)), vec![already]); } + /// #3425: the open parameters after a file reach the running app as they were given. Made + /// absolute with the file, `a//b/./c` became `a/b/c` (`a\b\c` on Windows), and on Windows + /// `x/../y` lost the file name and `Intro.` its dot. + #[test] + fn only_the_file_before_open_parameters_is_made_absolute() { + let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-open-parameters", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (manual, notes) = (dir.join("manual.pdf"), dir.join("notes#2.pdf")); + std::fs::write(&manual, b"%PDF-1.7").unwrap(); + std::fs::write(¬es, b"%PDF-1.7").unwrap(); + let made = |p: &std::path::Path| std::path::absolute(p).unwrap().to_string_lossy().into_owned(); + for parameters in ["#nameddest=a//b/./c", "#nameddest=x/../y", "#Intro.", "#page=2"] { + let given = format!("{}{parameters}", manual.to_string_lossy()); + assert_eq!(absolute(&[given]), vec![format!("{}{parameters}", made(&manual))], "{parameters}"); + } + // A file with `#` in its name is made absolute whole, as before. + assert_eq!(absolute(&[notes.to_string_lossy().into_owned()]), vec![made(¬es)]); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn each_windows_session_gets_its_own_socket_name() { // The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index 99c3264006..e91bb94e0a 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -123,7 +123,7 @@ pub fn is_comment_subtype(subtype: &str) -> bool { /// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect` /// follows it, within a few steps (a cycle stops there). -fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { +pub(crate) fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { for _ in 0..8 { match &*doc.get(r) { Object::Ref(next) => r = *next, @@ -340,6 +340,9 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { let direct = crate::draw::with_direct_values(doc, raw); let d = direct.as_ref(); let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); + if !is_comment_subtype(&subtype) { + return None; + } // A text box reports the colour and size it is drawn in (`/DA`, or `/DS` where it sets one). let style = (subtype == "FreeText").then(|| text_style::text_style(d, None)); let color = match &style { diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index 9af83b0542..f8681f899b 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -1,4 +1,4 @@ -use super::info::text_value; +use super::info::{is_comment_subtype, text_value}; use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape}; use super::{AnnotError, Dict, Document, ObjRef, Object, appearance}; @@ -106,16 +106,23 @@ pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result Ok(()) } -/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted). +/// Whether the annotation dictionary `d` has a comment `/Subtype` ([`is_comment_subtype`]). +pub(crate) fn is_comment_dict(d: &Dict) -> bool { + d.name(b"Subtype").is_some_and(|s| is_comment_subtype(&String::from_utf8_lossy(s))) +} + +/// The comment annotation at `(page, index)` as an indirect object (inline dictionaries are promoted). pub(crate) fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { let p = page_ref(doc, page)?; let mut list = annots(doc, p); let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; let r = match entry { - Object::Ref(r) if doc.get(r).as_dict().is_some() => r, - Object::Dict(d) => { + Object::Ref(r) if doc.get(r).as_dict().is_some_and(is_comment_dict) => r, + Object::Dict(d) if is_comment_dict(&d) => { let r = doc.add(Object::Dict(d)); - list[index] = Object::Ref(r); + if let Some(slot) = list.get_mut(index) { + *slot = Object::Ref(r); + } set_annots(doc, p, list)?; r } diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index eb77e3f973..c478c98a16 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -133,6 +133,10 @@ fn deleted_index(doc: &Document, page: usize, index: usize) -> Result Value { use pdfcraft_engine::js::Request as R; match r { @@ -14,6 +14,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::Beep => json!({ "beep": true }), R::Highlight(on) => json!({ "highlight": on }), R::SaveAs => json!({ "save_as": true }), + R::OpenAttachment { name, open } => json!({ "attachment": name, "open": open }), R::ShowPanel(p) => json!({ "panel": p.id() }), } } diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index e1439aa89f..c00034521c 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -104,12 +104,27 @@ impl Automation { } pub(crate) fn doc_open(&mut self, a: &Args) -> Result { - let path = self.resolve(a.str("path")?, false)?; + let requested = a.str("path")?; + // `manual.pdf#nameddest=Intro`, as a help system starts a PDF reader (#3425): the file under + // --root, and where it opens. A path that names a file is never split. + let parameters = pdfcraft_engine::open_parameters::split(requested, |p| self.resolve(p, false).is_ok_and(|p| p.is_file())); + let (requested, destination) = match ¶meters { + Some((file, destination)) => (file.as_str(), Some(destination.clone())), + None => (requested, None), + }; + let path = self.resolve(requested, false)?; let bytes = read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; - Ok(summary(doc)) + let mut value = summary(doc); + if let Some(destination) = destination { + let (applied, destination, warning) = crate::links::destination_report(doc, destination); + value["destination_applied"] = json!(applied); + value["destination"] = destination; + value["warning"] = json!(warning); + } + Ok(value) } pub(crate) fn doc_close(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 2fd51038de..37f1ed2cbb 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -176,7 +176,7 @@ fn calculate_json(f: &FormField) -> Option { match (&f.actions.calculate, &f.actions.scripts.calculate) { (Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })), (Calculate::Notation(n), _) => Some(json!({ "notation": n })), - (Calculate::None, Some(js)) => Some(json!({ "script": js })), + (Calculate::Custom(js), _) | (Calculate::None, Some(js)) => Some(json!({ "script": js })), (Calculate::None, None) => None, } } diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index a6228cc5bc..a876a77c91 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -11,6 +11,26 @@ use std::path::Path; use crate::comments::parse_color; use crate::{Args, Automation, Result, ToolError, failed}; +/// Where `destination` puts `doc`, a document just opened from a link or with open parameters: +/// whether it applies, the destination as reported, and a warning when it doesn't. A named +/// destination is looked up in the document, as the app does. +pub(crate) fn destination_report(doc: &pdfcraft_engine::Document, destination: RemoteDestination) -> (bool, Value, Option<&'static str>) { + let (name, page, view) = match destination { + RemoteDestination::Default => return (true, json!({ "kind": "default" }), None), + RemoteDestination::Page { page, view } => (None, page, view), + RemoteDestination::Named(name) => match pdfcraft_render::named_destination(doc.bytes.clone(), doc.password.as_deref(), &name) { + Some((page, view)) => (Some(name), page, view), + None => return (false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied.")), + }, + }; + let mut value = json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }); + if let Some(name) = name { + value["kind"] = json!("named"); + value["name"] = json!(name); + } + if page < doc.info.pages.len() { (true, value, None) } else { (false, value, Some("The PDF opened, but the page destination is out of range.")) } +} + fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) } @@ -222,20 +242,7 @@ impl Automation { let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), std::sync::Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; let opened = self.session.get(id).ok_or_else(|| failed("the linked document vanished"))?; - let (destination_applied, destination_value, warning) = match destination { - RemoteDestination::Default => (true, json!({ "kind": "default" }), None), - RemoteDestination::Named(name) => { - (false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied.")) - } - RemoteDestination::Page { page, view } if page < opened.info.pages.len() => { - (true, json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }), None) - } - RemoteDestination::Page { page, view } => ( - false, - json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }), - Some("The PDF opened, but the link's page destination is out of range."), - ), - }; + let (destination_applied, destination_value, warning) = destination_report(opened, destination); let mut display_path = path.to_string_lossy().into_owned(); if let Some(root) = &self.root && let Ok(relative) = path.strip_prefix(root) diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 7bab26e6c1..b57bce0176 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -412,7 +412,9 @@ pub(crate) fn rendered_content(out: &pdfcraft_render::RenderedPage, page: usize) fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str { use pdfcraft_render::RenderWarning; match warning { - RenderWarning::ContentTruncated => "content past the page's safety budget was skipped", + RenderWarning::ContentTruncated => { + "content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped" + } RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour", RenderWarning::MalformedIndexedImage => "an image with a malformed Indexed colour table may be drawn wrongly or not at all", RenderWarning::Type3WidthsMissing => "a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give", diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index cd98596b9c..5cb279ea1c 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -227,6 +227,7 @@ impl Automation { s => return Err(bad(format!("unknown booklet_subset {s:?}"))), }, binding: if a.opt_str("binding")? == Some("right") { Binding::Right } else { Binding::Left }, + creep: a.opt_num("creep")?.unwrap_or(0.0), }, "poster" => Layout::Poster { scale: a.opt_num("scale")?.unwrap_or(200.0), @@ -301,7 +302,7 @@ mod tests { /// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error. #[test] fn a_booklet_decides_two_sided_itself() { - let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left }; + let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left, creep: 0.0 }; for asked in ["printer", "off", "long-edge", "short-edge"] { assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index bf27b7dfa6..cc1046387b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -183,7 +183,7 @@ pub fn tools() -> Vec { )), t("attachment_edit", "Edit an attachment description", "Change the description of a document attachment by its exact key from doc_info, or by a unique displayed name. An empty description clears it. The file and other attachment data are preserved. Undoable.") .with(schema(json!({"doc": doc(), "key": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "description"])), - t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.") + t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited. A path that ends in open parameters, as help systems pass them (`manual.pdf#page=12`, `#nameddest=Intro` or `#Intro`), opens the file and reports its destination as link_open does; a path that names a file opens as it is.") .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])), @@ -212,7 +212,7 @@ pub fn tools() -> Vec { )), t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value, and a custom property is kept there as pdfx: when its name can be an XML name (in a PDF/A file only one already there is changed). Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), - t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.") + t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a picture left out because its JBIG2 mask is past a decoder limit, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 }, "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." }, @@ -764,6 +764,7 @@ pub fn tools() -> Vec { "auto_rotate": { "type": "boolean" }, "booklet_subset": { "type": "string", "enum": ["both", "front", "back"] }, "binding": { "type": "string", "enum": ["left", "right"] }, + "creep": { "type": "number", "minimum": 0, "maximum": 10, "description": "Booklet creep compensation: points each sheet's pages move toward the fold, times the sheet's place from the outside (default 0)." }, "overlap": { "type": "number", "minimum": 0 }, "cut_marks": { "type": "boolean" }, "orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] }, @@ -773,7 +774,7 @@ pub fn tools() -> Vec { "printer": { "type": "string" }, "copies": { "type": "integer", "minimum": 1, "maximum": 999 }, "collate": { "type": "boolean" }, - "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer sends no setting, so the printer's own default applies." }, + "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer uses the printer's current default: Windows sends its duplex setting when supported, otherwise preserves the driver default, including manual two-sided preferences; CUPS leaves the setting unspecified." }, "grayscale": { "type": "boolean" }, "area": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Print a selected area: [x0, y0, x1, y1] in points from the top-left of the displayed page, of the one page in pages, sized by layout fit/actual/shrink/custom." }, "as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey, so the printer isn't asked for grayscale (a driver without that setting still prints them)." }, @@ -855,7 +856,7 @@ pub fn tools() -> Vec { t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom). A relative url also has resolved_url, the address resolved against the document's base URI (catalog /URI /Base); a link active only in its /QuadPoints has active_areas, one rect per quadrilateral. With text_links: true, also the web and email addresses written as plain page text with no link over them, which the viewer opens on a click: page, rects (one per line), url, and blocked (why it won't open) when the link check refuses it. Only http, https and mailto addresses are found. At most 100 pages are read per call, from text_links_from (default 1); text_links_next_page then gives the page to continue from.") .ro() .with(schema(json!({ "doc": doc(), "text_links": { "type": "boolean" }, "text_links_from": { "type": "integer", "minimum": 1 } }), &["doc"])), - t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.") + t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. A named destination is looked up in the target, as the app does; one the target doesn't define is reported with a warning. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }), &["doc", "page", "index"], @@ -1396,7 +1397,7 @@ pub fn tools() -> Vec { t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), - t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.") + t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit, attachment) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.") .cmd("tools.js_console") .with(schema( json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index c847a67280..44daf8ae5c 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9,7 +9,16 @@ use serde_json::{Value, json}; /// A PDF with `n` 200×300 pt pages reading "Page 1", "Page 2", … fn fixture(n: usize) -> Vec { - let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; + fixture_with(n, "") +} + +/// Named destinations for [`fixture_with`]: `chapter` (page 2) in the catalog's /Dests and +/// `Load Flow` (page 3) in the /Names /Dests tree. +const NAMED_DESTS: &str = "/Dests << /chapter [6 0 R /Fit] >> /Names << /Dests << /Names [(Load Flow) [8 0 R /XYZ 0 300 0]] >> >>"; + +/// [`fixture`] with `catalog` added to the catalog dictionary. Page `i` (0-based) is object `4 + 2i`. +fn fixture_with(n: usize, catalog: &str) -> Vec { + let mut objs: Vec> = vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>").into_bytes()]; let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes()); objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec()); @@ -4592,6 +4601,51 @@ fn a_line_split_across_content_streams_is_listed_and_edited() { assert_eq!(page_text(&mut a, doc)[0].split_whitespace().collect::>(), ["Target", "Later"]); } +#[test] +fn mixed_script_paragraph_keeps_source_fonts_through_tools_and_reopen() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("skipping mixed-script fallback: build with CRAFT_FONTS_DIR"); + return; + } + let dir = workdir("mixed-script-paragraph"); + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + cos.update_dict(pdfcraft_cos::ObjRef::new(2, 0), |d| { + d.set(b"MediaBox".to_vec(), pdfcraft_cos::Object::Array([0, 0, 612, 792].map(pdfcraft_cos::Object::Int).to_vec())); + }) + .unwrap(); + let body = + b"BT /F1 12 Tf 14.4 TL 72 700 Td (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET"; + cos.set(pdfcraft_cos::ObjRef::new(5, 0), pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), body))); + std::fs::write(dir.join("mixed.pdf"), pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"mixed.pdf"}))["doc"].as_u64().unwrap(); + let draft = "The quick brown fox jumps over the lazy dog шалом and keeps running through the long field."; + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":draft})); + let check = |a: &mut Automation, doc, expected: &str| { + let listed = ok(a, "text_lines", json!({"doc":doc,"page":1})); + let lines = listed["lines"].as_array().unwrap(); + assert!(lines.iter().any(|l| l["font"] == "Helvetica" && l["text"].as_str().unwrap().contains("The quick brown fox")), "{listed}"); + for line in lines.iter().filter(|l| l["text"].as_str().unwrap().chars().any(|c| c.is_ascii_alphabetic())) { + assert_eq!(line["font"], "Helvetica", "{line}"); + } + let text = page_text(a, doc).join(" "); + assert_eq!(text.split_whitespace().collect::>(), expected.split_whitespace().collect::>()); + }; + check(&mut a, doc, draft); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert!(!page_text(&mut a, doc).join(" ").contains("шалом")); + ok(&mut a, "edit_redo", json!({"doc":doc})); + check(&mut a, doc, draft); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"})); + ok(&mut a, "doc_close", json!({"doc":doc})); + let doc = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + check(&mut a, doc, draft); + let extended = format!("{draft} More Latin."); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":extended})); + check(&mut a, doc, &extended); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn paragraph_bold_without_font_keeps_the_source_family() { let dir = workdir("paragraph-bold"); @@ -6200,6 +6254,9 @@ fn printing_through_tools() { assert_eq!(ok(&mut a, "doc_info", json!({ "doc": printed }))["document"]["pages"].as_u64(), Some(n.div_ceil(4))); let r = ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "layout": "poster", "scale": 400, "path": "poster.pdf" })); assert!(r["sheets"].as_u64().unwrap() > 1); + let r = ok(&mut a, "doc_print", json!({ "doc": doc, "layout": "booklet", "creep": 1.5, "path": "booklet.pdf" })); + assert_eq!(r["sheets"].as_u64(), Some(n.div_ceil(4) * 2), "creep keeps the booklet's sheets"); + assert!(a.call("doc_print", &json!({ "doc": doc, "layout": "booklet", "creep": -1, "path": "x.pdf" })).is_err(), "negative creep"); assert!(matches!(a.call("doc_print", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); assert!(matches!(a.call("doc_print", &json!({ "doc": doc, "pages": "99", "path": "x.pdf" })), Err(ToolError::InvalidArgs(_)))); } @@ -7350,6 +7407,43 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() { } } +#[test] +fn doc_open_reads_open_parameters_after_the_file_name() { + // #3425: help systems start a PDF reader with `manual.pdf#nameddest=…` or `#page=…`. + let (_base, root) = sandbox("open-parameters"); + std::fs::write(root.join("manual.pdf"), fixture_with(3, NAMED_DESTS)).unwrap(); + std::fs::write(root.join("odd#page=2.pdf"), fixture(1)).unwrap(); + let mut a = auto(&root); + for (path, kind, page) in [("manual.pdf#page=3", "page", 3), ("manual.pdf#nameddest=Load Flow", "named", 3), ("manual.pdf#chapter", "named", 2)] { + let opened = ok(&mut a, "doc_open", json!({ "path": path })); + assert_eq!((opened["name"].as_str(), opened["pages"].as_u64()), (Some("manual.pdf"), Some(3)), "{path}"); + assert_eq!(opened["destination_applied"], true, "{path}: {opened}"); + assert_eq!((opened["destination"]["kind"].as_str(), opened["destination"]["page"].as_u64()), (Some(kind), Some(page)), "{path}"); + } + for (path, warning) in + [("manual.pdf#nameddest=Nowhere", "named destination"), ("manual.pdf#page=9", "out of range"), ("manual.pdf#page=99999", "out of range")] + { + let opened = ok(&mut a, "doc_open", json!({ "path": path })); + assert_eq!(opened["destination_applied"], false, "{path}"); + assert!(opened["warning"].as_str().is_some_and(|w| w.contains(warning)), "{path}: {opened}"); + } + // A parameter that can't be applied isn't a failure: the file opens at its default view. + let opened = ok(&mut a, "doc_open", json!({ "path": "manual.pdf#page=0" })); + assert_eq!(opened["pages"].as_u64(), Some(3)); + assert_eq!((opened["destination_applied"].as_bool(), opened["destination"]["kind"].as_str()), (Some(true), Some("default")), "{opened}"); + assert!(opened["warning"].is_null(), "{opened}"); + // A file whose own name has a `#` opens as itself, and a plain path reports no destination. + let opened = ok(&mut a, "doc_open", json!({ "path": "odd#page=2.pdf" })); + assert_eq!((opened["name"].as_str(), opened["pages"].as_u64()), (Some("odd#page=2.pdf"), Some(1))); + assert!(opened.get("destination").is_none(), "{opened}"); + // The file before the `#` must exist, inside --root. + let before = a.session().docs().len(); + for path in ["missing.pdf#page=2", "../outside/secret.pdf#page=1"] { + err(&mut a, "doc_open", json!({ "path": path })); + assert_eq!(a.session().docs().len(), before, "{path}"); + } +} + #[test] fn local_gotor_link_open_applies_destinations_and_honors_root() { let root = local_gotor_fixtures(); @@ -7357,6 +7451,7 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() { assert_relative_gotor_destination(&mut a); assert_absolute_gotor_policy(&mut a); assert_unapplied_gotor_destinations(&mut a); + assert_named_gotor_destination(&mut a); assert_encrypted_gotor_passwords(&mut a); assert_unsupported_gotor_targets(&mut a); assert_gotor_root_boundaries(&mut a); @@ -7371,6 +7466,8 @@ fn local_gotor_fixtures() -> PathBuf { std::fs::write(root.join("sub/source.pdf"), remote_link_pdf("../target.pdf", Some("[2 /FitH 180]"))).unwrap(); std::fs::write(root.join("sub/escape.pdf"), remote_link_pdf("../../outside/secret.pdf", Some("[0 /Fit]"))).unwrap(); std::fs::write(root.join("named.pdf"), remote_link_pdf("target.pdf", Some("(chapter)"))).unwrap(); + std::fs::write(root.join("named-target.pdf"), fixture_with(3, NAMED_DESTS)).unwrap(); + std::fs::write(root.join("named-found.pdf"), remote_link_pdf("named-target.pdf", Some("(chapter)"))).unwrap(); std::fs::write(root.join("range.pdf"), remote_link_pdf("target.pdf", Some("[99 /Fit]"))).unwrap(); std::fs::write(root.join("url.pdf"), remote_link_pdf("https://example.org/target.pdf", Some("[0 /Fit]"))).unwrap(); std::fs::write(root.join("missing-source.pdf"), remote_link_pdf("does-not-exist.pdf", Some("[0 /Fit]"))).unwrap(); @@ -7419,6 +7516,14 @@ fn assert_unapplied_gotor_destinations(a: &mut Automation) { } } +fn assert_named_gotor_destination(a: &mut Automation) { + // A name the target defines is looked up there, as the app does. + let source = ok(a, "doc_open", json!({ "path": "named-found.pdf" }))["doc"].as_u64().unwrap(); + let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); + assert_eq!(opened["destination_applied"], true, "{opened}"); + assert_eq!(opened["destination"], json!({ "kind": "named", "name": "chapter", "page": 2, "view": { "kind": "fit" } })); +} + fn assert_encrypted_gotor_passwords(a: &mut Automation) { let password_source = ok(a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); diff --git a/crates/automation/tests/it/render_warnings.rs b/crates/automation/tests/it/render_warnings.rs index 013c287d5d..aba200d3d8 100644 --- a/crates/automation/tests/it/render_warnings.rs +++ b/crates/automation/tests/it/render_warnings.rs @@ -123,6 +123,45 @@ fn page_render_reports_a_recovered_malformed_indexed_image() { let _ = std::fs::remove_dir_all(dir); } +/// A picture whose JBIG2 soft mask is past one of the decoder's limits isn't drawn (#6722), and +/// `page_render` says why the page is incomplete, in the words `pdfcraft-cli render` prints. +#[test] +fn page_render_says_a_picture_was_left_out_for_its_jbig2_mask() { + let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-jbig2-mask-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // One JBIG2 segment (ISO 14492 §7.4.8): the information of a page 65,536 columns wide, a + // column past the decoder's bound on a side, as the mask's dictionary says too. + let mut mask = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19]; + mask.extend_from_slice(&65536u32.to_be_bytes()); + mask.extend_from_slice(&2u32.to_be_bytes()); + mask.extend_from_slice(&[0; 11]); // resolution, flags, striping + let content = "q 200 0 0 200 0 0 cm /Im Do Q"; + let mut pdf = 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 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n\ + 6 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", + content.len(), + mask.len() + ) + .into_bytes(); + pdf.extend_from_slice(&mask); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + std::fs::write(dir.join("mask.pdf"), pdf).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = open(&mut a, "mask.pdf"); + let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") }; + assert_eq!( + report["warnings"], + json!(["content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped"]) + ); + let _ = std::fs::remove_dir_all(dir); +} + /// Over MCP the warnings are a text block after the image and the call's `structuredContent`. #[cfg(feature = "mcp")] #[test] diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 56286ce4a0..0067304f66 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -246,12 +246,14 @@ pub struct DecodeBudget { bytes: usize, forms: usize, seen: HashSet, + /// Decodes refused for lack of allowance ([`DecodeBudget::refused`]). + refusals: usize, } impl DecodeBudget { /// An allowance of `bytes` decoded bytes and `forms` form entries. pub fn new(bytes: usize, forms: usize) -> Self { - Self { limit: bytes, bytes, forms, seen: HashSet::new() } + Self { limit: bytes, bytes, forms, seen: HashSet::new(), refusals: 0 } } /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms. @@ -264,6 +266,22 @@ impl DecodeBudget { self.bytes } + /// How many decodes were refused for lack of allowance: one that came once it was spent, or + /// ran past what was left. A decode that spent exactly what was left wasn't refused, so an + /// allowance used up to the byte has none. + pub fn refused(&self) -> usize { + self.refusals + } + + /// Whether the allowance is spent and `stream`, which has data, would be refused, noting the + /// refusal ([`DecodeBudget::refused`]): a caller asks this before resolving the stream's + /// parameters, work the allowance doesn't count. A stream without data still reads. + pub fn refuses(&mut self, stream: &Stream) -> bool { + let refuses = self.bytes == 0 && !stream.raw.is_empty(); + self.refusals += usize::from(refuses); + refuses + } + /// Whether a decode was refused for lack of allowance or a form entry for lack of visits. pub fn exhausted(&self) -> bool { self.bytes == 0 || self.forms == 0 @@ -283,6 +301,51 @@ impl DecodeBudget { self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data) } + /// Like [`DecodeBudget::decode_within`], with every filter stage held to `max`, as + /// [`Stream::decoded_within`] holds it, and the stages together to `max` for each of them, the + /// stream's `/DecodeParms` given by reference read in `doc` through a view of it that shares + /// its data and copies no more of its dictionary than its filters and parameters + /// ([`DecodeBudget::decode_with_damage`] reads it so). A + /// stream so reads here when it reads without an allowance and the allowance can pay for + /// what its stages decode, the output of one stage on top of another's. A stage past `max` + /// is an error that charges what the stages before it decoded and `max`, the work it took to + /// find out; past the stages' total it charges that total, and past what is left it spends + /// all of it, as [`DecodeBudget::decode_within`] does. + pub fn decode_stages_within(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result, CosError> { + // Before the parameters are resolved, work the allowance doesn't count. + if self.refuses(stream) { + return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); + } + let stream = doc.decoding_view(stream); + let total = max.saturating_mul(stream.filters().len().max(1)); + let room = total.min(self.bytes); + match stream.decoded_within_each_counted(max, room) { + Ok((data, _, spent)) => { + self.bytes = self.bytes.saturating_sub(spent); + Ok(data) + } + // A stage stopped at `max`, below what was left of the room. + Err((FilterError::LimitExceeded(_), spent)) if room.saturating_sub(spent) > max => { + self.bytes = self.bytes.saturating_sub(spent.saturating_add(max)); + Err(CosError::Filter(FilterError::LimitExceeded(max).to_string())) + } + // At the stages' total, with more left. + Err((FilterError::LimitExceeded(_), _)) if total < self.bytes => { + self.bytes = self.bytes.saturating_sub(total); + Err(CosError::Filter(FilterError::LimitExceeded(total).to_string())) + } + Err((FilterError::LimitExceeded(_), _)) => { + self.bytes = 0; + self.refusals += 1; + Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) + } + Err((e, spent)) => { + self.bytes = self.bytes.saturating_sub(spent); + Err(CosError::Filter(e.to_string())) + } + } + } + /// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), with the stream's /// `/DecodeParms` given by reference read in `doc`, also saying whether the stream is damaged: /// `true` when a filter stopped at corrupt data, so the bytes are only what decoded before it. @@ -304,7 +367,7 @@ impl DecodeBudget { /// pays for the bytes decoded before the error, so referencing a stream again and again /// spends the allowance by the work each decode does. fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<(Vec, bool), CosError> { - if self.bytes == 0 && !stream.raw.is_empty() { + if self.refuses(stream) { return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } if max < self.bytes { @@ -324,6 +387,7 @@ impl DecodeBudget { } Err((FilterError::LimitExceeded(_), _)) => { self.bytes = 0; + self.refusals += 1; Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) } Err((e, spent)) => { @@ -1025,4 +1089,99 @@ mod tests { assert!(budget.decode_strict(&Document::new_empty(), &chained).is_ok()); assert_eq!(budget.remaining(), (10 << 20) - 3 * charged); } + + /// `decode_stages_within` holds every stage to the limit, as a decode without an allowance + /// does, and the stages together to the limit for each of them. A stream behind ASCIIHex and + /// Flate whose stages each stay within it reads, the hex stage's output charged on top; one + /// whose Flate stage would make more stops there, charged what the hex stage made and the + /// limit, the rest of the allowance left; a Flate stage making hex text past the limit leaves + /// the stream unread though the total has room, as `Stream::decoded_within` leaves it; and a + /// stream past what is left spends all of it. + #[test] + fn each_stage_is_held_to_the_limit() { + let chained = |data: &[u8], filters: [&str; 2]| { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(filters.into_iter().map(Object::name).collect())); + Stream::from_raw(dict, data.to_vec()) + }; + let hexed = |data: &[u8]| { + let flate = Stream::flate(Dict::new(), data); + let hex: Vec = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect(); + (chained(&hex, ["ASCIIHexDecode", "FlateDecode"]), flate.raw.len()) + }; + let max = 1 << 16; + let (fits, inner) = hexed(&vec![0u8; max]); + let mut budget = DecodeBudget::new(1 << 20, 0); + assert_eq!(budget.decode_stages_within(&Document::new_empty(), &fits, max).map(|d| d.len()).ok(), Some(max)); + assert_eq!(budget.remaining(), (1 << 20) - inner - max); + let (bomb, inner) = hexed(&vec![0u8; 8 * max]); + let mut budget = DecodeBudget::new(1 << 20, 0); + assert!(budget.decode_stages_within(&Document::new_empty(), &bomb, max).is_err()); + assert_eq!(budget.remaining(), (1 << 20) - inner - max, "the Flate stage stopped at the limit"); + // Flate making 1.2 times the limit of hex text, which ASCIIHex halves: the stages together + // make 1.8 times the limit, within twice it, but the Flate stage alone is past it. + let text = "00".repeat(max * 6 / 10).into_bytes(); + let wide = chained(&Stream::flate(Dict::new(), &text).raw, ["FlateDecode", "ASCIIHexDecode"]); + assert!(wide.decoded_within(max).is_err()); + assert!(DecodeBudget::new(1 << 20, 0).decode_stages_within(&Document::new_empty(), &wide, max).is_err()); + let mut budget = DecodeBudget::new(max, 0); + assert!(budget.decode_stages_within(&Document::new_empty(), &fits, max).is_err()); + assert_eq!(budget.remaining(), 0); + } + + /// What `decode_stages_within` charges, and which limit it reports, on each way a read stops: + /// a stage at the stream's limit with more of the room left charges what came before and the + /// limit; at the stages' total, with more of the allowance left, it charges that total; at + /// what is left of the allowance it spends all of it and reports the allowance's limit, as + /// `decode_within` does. Each boundary between them is read on both sides. + #[test] + fn a_stage_past_its_limit_charges_what_came_before_and_the_limit() { + let max = 1 << 12; + let hex = |data: &[u8]| data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect::>(); + let two = |raw: &[u8]| { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, hex(raw)) + }; + let says = |read: Result, CosError>, limit: usize| { + read.err().map(|e| e.to_string()) == Some(CosError::Filter(FilterError::LimitExceeded(limit).to_string()).to_string()) + }; + // Flate data making eight times the limit: the hex stage makes `inner` bytes of it, and + // the Flate stage stops at the limit. + let bomb = Stream::flate(Dict::new(), &vec![0u8; 8 * max]).raw.to_vec(); + let inner = bomb.len(); + // A byte more of the room than the hex stage and the limit: the Flate stage stops at the + // limit, so that is what it charges. + let mut budget = DecodeBudget::new(inner + max + 1, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), max)); + assert_eq!(budget.remaining(), 1); + // Exactly the hex stage and the limit: what was left stopped it as much as the limit did, + // so it is past the allowance, spends it and reports the allowance's limit. + let mut budget = DecodeBudget::new(inner + max, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), inner + max)); + assert_eq!(budget.remaining(), 0); + // The same data padded to exactly the limit: the hex stage makes all of it, and the Flate + // stage, at the limit, reaches the stages' total. With more of the allowance left, that + // total is what it charges and reports. + let mut padded = bomb.clone(); + padded.resize(max, 0); + let mut budget = DecodeBudget::new(10 * max, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 2 * max)); + assert_eq!(budget.remaining(), 8 * max); + // With exactly the total left (of an allowance of three times the limit), it is past the + // allowance: all of it is spent, and the allowance's limit reported. + let mut budget = DecodeBudget::new(3 * max, 0); + budget.decode(&Stream::from_raw(Dict::new(), vec![0; max])).unwrap(); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 3 * max)); + assert_eq!(budget.remaining(), 0); + // With less than the total left, the same. + let mut budget = DecodeBudget::new(max, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), max)); + assert_eq!(budget.remaining(), 0); + // With nothing left, a stream with data is refused before it is decoded, even one that + // decodes to nothing; an empty one reads. + let mut budget = DecodeBudget::new(0, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &Stream::flate(Dict::new(), b""), max), 0)); + assert_eq!(budget.decode_stages_within(&Document::new_empty(), &Stream::from_raw(Dict::new(), Vec::new()), max).ok(), Some(Vec::new())); + } } diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 2bf4bce42b..f3ea35d8c8 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -19,6 +19,7 @@ mod reader; mod state; mod xref; +pub use objects::DECODE_PARMS_KEYS; pub(crate) use objects::Held; #[cfg(test)] pub(crate) use reader::find; diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 8f9d3a8a5b..925a7c879d 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -507,17 +507,6 @@ impl Document { pdfcraft_filters::decode_ccitt_within(&data, &p, rows, work).map_err(filter) } - /// The `/JBIG2Globals` stream a JBIG2 image `s` names by reference in its last filter's - /// `/DecodeParms`: `None` for a stream whose last filter isn't `JBIG2Decode`, or one without - /// globals. Redaction finds with it the other images that share a redacted image's globals. - pub fn jbig2_globals_ref(&self, s: &Stream) -> Option { - let chain = s.readable_filters().ok()?; - if !matches!(chain.last(), Some((pdfcraft_filters::Filter::Jbig2, _))) { - return None; - } - self.last_filter_parms(s).get(b"JBIG2Globals").and_then(Object::as_ref) - } - /// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`) /// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is /// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read @@ -1079,7 +1068,7 @@ impl Document { /// The `/DecodeParms` entries the decoders read (ISO 32000-2 Tables 8, 11 and 13: the Flate and /// LZW predictors, CCITT, DCT's colour transform): numbers and flags a file may give by /// reference, which [`Dict::int`] doesn't follow. -const DECODE_PARMS_KEYS: [&[u8]; 13] = [ +pub const DECODE_PARMS_KEYS: [&[u8]; 13] = [ b"Predictor", b"Colors", b"BitsPerComponent", diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index 274672bc7c..68215efc98 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -1,5 +1,26 @@ use super::*; +/// What [`crate::reachable_objects`] gives is what a full save keeps: not an object nothing refers to, nor one +/// a reference only dangles at, and a ring of references ends. +#[test] +fn what_is_reachable_is_what_a_full_save_keeps() { + let mut doc = Document::new_empty(); + let root = doc.root().unwrap(); + let kept = doc.add(Object::Int(7)); + let dangling = ObjRef::new(4_000, 0); + let list = doc.add(Object::Null); + let ring = doc.add(Object::Array(vec![Object::Ref(list)])); + doc.set(list, Object::Array(vec![Object::Ref(kept), Object::Ref(ring), Object::Ref(dangling)])); + let orphan = doc.add(Object::Int(8)); + doc.update_dict(root, |d| d.set(b"Held".to_vec(), Object::Ref(list))).unwrap(); + let found = crate::reachable_objects(&doc).unwrap(); + assert!([root, kept, list, ring].iter().all(|r| found.contains(&r.num))); + assert!(!found.contains(&orphan.num) && !found.contains(&dangling.num)); + let saved = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap(); + let holds = |n: i64| saved.scan_objects().any(|(_, o)| matches!(*o, Object::Int(v) if v == n)); + assert!(holds(7) && !holds(8)); +} + std::thread_local! { /// How many times `Document::scan_for` read the file on this thread. pub(super) static FILE_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 7f7f32fedc..5de573294d 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -24,7 +24,7 @@ mod writer; pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS, MAX_REDACTED_IMAGE_PIXELS}; pub use bytes::Bytes; -pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; +pub use document::{DECODE_PARMS_KEYS, Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream, TextChars}; pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 97a3b60ab9..f2c9760c12 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -384,6 +384,26 @@ impl Stream { self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent)) } + /// [`Stream::decoded_within_counted`] with every filter stage also held to `each`, as + /// [`Stream::decoded_within`] holds it, while `max` bounds the stages together + /// ([`pdfcraft_filters::decode_tolerant_counted_each`]). Without filters the data is held to + /// both. + pub(crate) fn decoded_within_each_counted( + &self, + each: usize, + max: usize, + ) -> Result<(Vec, bool, usize), (pdfcraft_filters::FilterError, usize)> { + let mut damaged = false; + let decode = |chain: &[(Filter, Params)], data: &[u8], max| { + pdfcraft_filters::decode_tolerant_counted_each(chain, data, each.min(MAX_DECODED), max).map(|(v, partial, spent)| { + damaged = partial; + (v, spent) + }) + }; + let max = if self.readable_filters().is_ok_and(|chain| chain.is_empty()) { max.min(each) } else { max }; + self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent)) + } + /// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a /// document's images ([`pdfcraft_filters::decode_tolerant_within`]). pub(crate) fn decoded_spending(&self, max: usize, work: &mut u64) -> Result, CosError> { diff --git a/crates/crypt/Cargo.toml b/crates/crypt/Cargo.toml index 9edb008f2c..dcc7d2b935 100644 --- a/crates/crypt/Cargo.toml +++ b/crates/crypt/Cargo.toml @@ -16,6 +16,7 @@ md-5 = "0.11" sha2 = "0.11" stringprep = "0.1" thiserror.workspace = true +zeroize.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 9478e74130..8b520d281d 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -19,6 +19,7 @@ mod rc4; use md5::{Digest as _, Md5}; use sha2::{Sha256, Sha384, Sha512}; +use zeroize::Zeroizing; pub use aes_cbc::{aes_cbc_decrypt, aes_cbc_encrypt}; pub use rc4::rc4; @@ -147,9 +148,9 @@ pub enum StreamKind<'a> { #[derive(Clone, Debug)] pub struct SecurityHandler { dict: EncryptDict, - key: Vec, + key: Zeroizing>, auth: Auth, - recovered_user: Option>, + recovered_user: Option>>, methods: Methods, } @@ -190,7 +191,7 @@ impl Methods { } impl SecurityHandler { - fn new(dict: EncryptDict, key: Vec, auth: Auth, recovered_user: Option>) -> Self { + fn new(dict: EncryptDict, key: Zeroizing>, auth: Auth, recovered_user: Option>>) -> Self { let methods = Methods::new(&dict); Self { dict, key, auth, recovered_user, methods } } @@ -248,7 +249,7 @@ impl SecurityHandler { if methods.string != Method::Identity || methods.stream != Method::Identity { return None; } - Some(Self { dict, key: Vec::new(), auth: Auth::User, recovered_user: None, methods }) + Some(Self { dict, key: Zeroizing::new(Vec::new()), auth: Auth::User, recovered_user: None, methods }) } /// Whether the handler has the file key: false for [`SecurityHandler::without_key`]. @@ -289,7 +290,7 @@ impl SecurityHandler { /// For R2–R4 documents opened with the owner password: the user password, which owner /// authentication recovers (Algorithm 7). Lets components that only accept user passwords /// open the document too. - pub fn recovered_user_password(&self) -> Option> { + pub fn recovered_user_password(&self) -> Option>> { self.recovered_user.clone() } @@ -306,7 +307,7 @@ impl SecurityHandler { if !matches!(self.dict.r, 5 | 6) || !self.has_key() { return None; } - let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false); + let plain = Zeroizing::new(aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false)); let p = i32::from_le_bytes(*plain.first_chunk::<4>()?); (plain.get(9..12) == Some(&b"adb"[..])).then_some(p) } @@ -348,7 +349,7 @@ impl SecurityHandler { /// the same way, so a page it shows is one this crate can read and save. AES-128 keys are /// left as they are: `AESV2` takes a 128-bit key (Table 25), and Acrobat's extension is only /// seen with RC4. Every key PdfCraft writes under `/V 4` is 16 bytes already. - fn object_key(&self, method: Method, num: u32, generation: u16) -> Vec { + fn object_key(&self, method: Method, num: u32, generation: u16) -> Zeroizing> { if method == Method::Aes256 { return self.key.clone(); } @@ -363,8 +364,9 @@ impl SecurityHandler { if method == Method::Aes128 { h.update(b"sAlT"); } - let digest = h.finalize(); - digest[..(key.len() + 5).min(16)].to_vec() + let mut digest = Zeroizing::new(h.finalize().to_vec()); + digest.truncate((key.len() + 5).min(16)); + digest } fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec { @@ -456,31 +458,31 @@ pub fn pdfdoc_byte(c: char) -> Option { /// Revisions 2–4: the password in PDFDocEncoding (§7.6.4.3.2, Algorithm 2), at most 32 bytes. /// Characters PDFDocEncoding lacks are dropped. -fn legacy_password(pw: &str) -> Vec { - pw.chars().filter_map(pdfdoc_byte).take(32).collect() +fn legacy_password(pw: &str) -> Zeroizing> { + Zeroizing::new(pw.chars().filter_map(pdfdoc_byte).take(32).collect()) } /// The bytes an R2–R4 password may have been written as: PDFDocEncoding, as the specification /// asks; and for a password with characters PDFDocEncoding lacks (ğ, ş, İ, …), which it gives /// no bytes, also its UTF-8 bytes, which qpdf and other tools write for such passwords. -fn legacy_candidates(pw: &str) -> Vec> { +fn legacy_candidates(pw: &str) -> Vec>> { let mut out = vec![legacy_password(pw)]; if pw.chars().any(|c| pdfdoc_byte(c).is_none()) { - out.push(pw.bytes().take(32).collect()); + out.push(Zeroizing::new(pw.bytes().take(32).collect())); } out } /// Revisions 5–6: SASLprep (RFC 4013), UTF-8, at most 127 bytes. -fn sasl_password(pw: &str) -> Vec { - let prepped = stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string()); - let mut bytes = prepped.into_bytes(); +fn sasl_password(pw: &str) -> Zeroizing> { + let prepped = Zeroizing::new(stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string())); + let mut bytes = Zeroizing::new(prepped.as_bytes().to_vec()); bytes.truncate(127); bytes } -fn pad(pw: &[u8]) -> [u8; 32] { - let mut out = PADDING; +fn pad(pw: &[u8]) -> Zeroizing<[u8; 32]> { + let mut out = Zeroizing::new(PADDING); let n = pw.len().min(32); out[..n].copy_from_slice(&pw[..n]); out[n..].copy_from_slice(&PADDING[..32 - n]); @@ -494,20 +496,20 @@ fn key_len(d: &EncryptDict) -> usize { // ── Revisions 2–4 ────────────────────────────────────────────────────────────────────────────── /// Algorithm 2: file key from a (user) password. -fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Vec { +fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Zeroizing> { let n = key_len(d); let mut h = Md5::new(); - h.update(pad(pw)); + h.update(*pad(pw)); h.update(&d.o[..d.o.len().min(32)]); h.update(d.p.to_le_bytes()); h.update(id0); if d.r >= 4 && !d.encrypt_metadata { h.update([0xFF; 4]); } - let mut digest = h.finalize().to_vec(); + let mut digest = Zeroizing::new(h.finalize().to_vec()); if d.r >= 3 { for _ in 0..50 { - digest = Md5::digest(&digest[..n]).to_vec(); + digest = Zeroizing::new(Md5::digest(&digest[..n]).to_vec()); } } digest.truncate(n); @@ -524,7 +526,7 @@ fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec { h.update(id0); let mut x = rc4(key, &h.finalize()); for i in 1..=19u8 { - let k: Vec = key.iter().map(|b| b ^ i).collect(); + let k = Zeroizing::new(key.iter().map(|b| b ^ i).collect::>()); x = rc4(&k, &x); } x.extend_from_slice(&[0u8; 16]); @@ -536,7 +538,7 @@ fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec { /// Some producers ignore `/EncryptMetadata false` when deriving the key (they omit the /// `FF FF FF FF` of Algorithm 2, step f). Viewers open those files, so if the strict derivation /// fails we retry the other way. -fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option> { +fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option>> { let check = |d: &EncryptDict| { let key = file_key_r4(d, id0, pw); let u = u_value_r4(d, id0, &key); @@ -547,54 +549,57 @@ fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option> { } /// The RC4 key derived from an owner password (Algorithm 3, steps a–d). -fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Vec { +fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Zeroizing> { let n = key_len(d); - let mut digest = Md5::digest(pad(owner_pw)).to_vec(); + let mut digest = Zeroizing::new(Md5::digest(*pad(owner_pw)).to_vec()); if d.r >= 3 { for _ in 0..50 { - digest = Md5::digest(&digest).to_vec(); + digest = Zeroizing::new(Md5::digest(&*digest).to_vec()); } } digest.truncate(n); digest } +type SecretBytes = Zeroizing>; + /// Algorithm 7: authenticate an owner password (recover the user password from `/O`). /// Returns the file key and the recovered user password (padding removed). -fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(Vec, Vec)> { +fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(SecretBytes, SecretBytes)> { let rk = owner_rc4_key(d, pw); - let mut user = d.o[..d.o.len().min(32)].to_vec(); + let mut user = Zeroizing::new(d.o[..d.o.len().min(32)].to_vec()); if d.r == 2 { - user = rc4(&rk, &user); + user = Zeroizing::new(rc4(&rk, &user)); } else { for i in (0..=19u8).rev() { - let k: Vec = rk.iter().map(|b| b ^ i).collect(); - user = rc4(&k, &user); + let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::>()); + user = Zeroizing::new(rc4(&k, &user)); } } let key = user_key_r4(d, id0, &user)?; // The recovered value is the padded password: strip the padding suffix. let len = (0..=32).find(|&i| user.len() >= 32 && user[i..32] == PADDING[..32 - i]).unwrap_or(user.len().min(32)); - Some((key, user[..len].to_vec())) + user.truncate(len); + Some((key, user)) } // ── Revisions 5–6 ────────────────────────────────────────────────────────────────────────────── /// The hash of a password for R5 (SHA-256) or R6 (Algorithm 2.B). -fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec { - let mut k = { +fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Zeroizing> { + let mut k = Zeroizing::new({ let mut h = Sha256::new(); h.update(pw); h.update(salt); h.update(udata); h.finalize().to_vec() - }; + }); if r == 5 { return k; } let mut round = 0u32; loop { - let mut k1 = Vec::with_capacity(64 * (pw.len() + k.len() + udata.len())); + let mut k1 = Zeroizing::new(Vec::with_capacity(64 * (pw.len() + k.len() + udata.len()))); for _ in 0..64 { k1.extend_from_slice(pw); k1.extend_from_slice(&k); @@ -603,13 +608,13 @@ fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec { // `k` is always a SHA-2 digest (32, 48 or 64 bytes): key = its first 16 bytes, IV = the next 16. let Some((key, rest)) = k.split_first_chunk::<16>() else { break }; let Some(iv) = rest.first_chunk::<16>() else { break }; - let e = aes_cbc_encrypt(key, iv, &k1, false); + let e = Zeroizing::new(aes_cbc_encrypt(key, iv, &k1, false)); let m = e.iter().take(16).map(|b| u32::from(*b)).sum::() % 3; - k = match m { - 0 => Sha256::digest(&e).to_vec(), - 1 => Sha384::digest(&e).to_vec(), - _ => Sha512::digest(&e).to_vec(), - }; + k = Zeroizing::new(match m { + 0 => Sha256::digest(&*e).to_vec(), + 1 => Sha384::digest(&*e).to_vec(), + _ => Sha512::digest(&*e).to_vec(), + }); round += 1; if round >= 64 && e.last().is_none_or(|b| u32::from(*b) <= round - 32) { break; @@ -620,19 +625,19 @@ fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec { } /// Algorithm 2.A: authenticate as owner (`owner = true`) or user; returns the file key. -fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result>, CryptError> { +fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result>>, CryptError> { if d.u.len() < 48 || d.o.len() < 48 { return Err(CryptError::Malformed("/O and /U must be 48 bytes for R5/R6".into())); } let (hashed, encrypted, udata) = if owner { (&d.o[..48], &d.oe, &d.u[..48]) } else { (&d.u[..48], &d.ue, &[][..]) }; - if hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] { + if *hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] { return Ok(None); } if encrypted.len() < 32 { return Err(CryptError::Malformed("/OE or /UE must be 32 bytes".into())); } let k = hash_r6(d.r, pw, &hashed[40..48], udata); - Ok(Some(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false))) + Ok(Some(Zeroizing::new(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false)))) } // ── Creating encryption ──────────────────────────────────────────────────────────────────────── @@ -654,7 +659,7 @@ pub enum Algorithm { /// dropped), at most 32 bytes; for R6 its SASLprep (RFC 4013: soft hyphens and the like removed, /// other spaces made plain, then NFKC; the password as given if SASLprep refuses it), in UTF-8, /// at most 127 bytes. -fn prepared_password(algorithm: Algorithm, password: &str) -> Vec { +fn prepared_password(algorithm: Algorithm, password: &str) -> Zeroizing> { match algorithm { Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => legacy_password(password), Algorithm::Aes256 => sasl_password(password), @@ -672,7 +677,7 @@ pub fn effective_password(algorithm: Algorithm, password: &str) -> Vec { let prepared = prepared_password(algorithm, password); match algorithm { Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => pad(&prepared).to_vec(), - Algorithm::Aes256 => prepared, + Algorithm::Aes256 => prepared.to_vec(), } } @@ -737,10 +742,10 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result= 3 { for i in 1..=19u8 { - let k: Vec = rk.iter().map(|b| b ^ i).collect(); + let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::>()); o = rc4(&k, &o); } } @@ -754,15 +759,15 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result> = &h.key; + let _: &Option>> = &h.recovered_user; + assert_eq!(h.recovered_user_password().as_deref().map(Vec::as_slice), Some(&b"user pw"[..]), "{alg:?}"); assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap().recovered_user_password(), None); } let d = new(Algorithm::Rc4_128, "", "owner", -1).dict().clone(); - assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref(), Some(&b""[..])); + assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref().map(Vec::as_slice), Some(&b""[..])); } /// /O, /U and the first /ID of files qpdf 12.4.2 wrote (`--static-id`), with the file keys @@ -393,7 +395,7 @@ fn legacy_passwords_are_pdfdoc_encoded_with_a_utf8_fallback() { assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("3564ffcea186122595ad55ee157da71f"))); let owner = SecurityHandler::open(r3.clone(), &id, Some("Öğretmen")).unwrap(); assert_eq!((owner.auth(), owner.file_key().to_vec()), (Auth::Owner, hex("3564ffcea186122595ad55ee157da71f"))); - assert_eq!(owner.recovered_user_password(), Some(vec![0x9A, b'l', 0x9A, b'k', 0xA0])); + assert_eq!(owner.recovered_user_password(), Some(Zeroizing::new(vec![0x9A, b'l', 0x9A, b'k', 0xA0]))); // Dropping ı and € (the old Latin-1 conversion) or a near miss doesn't open it. for wrong in ["lk", "ilik€", "ılık", "Ogretmen"] { assert_eq!(SecurityHandler::open(r3.clone(), &id, Some(wrong)).unwrap_err(), CryptError::WrongPassword, "{wrong}"); @@ -423,6 +425,6 @@ fn pdfdoc_encoding_round_trips() { for c in ['ğ', 'ş', 'İ', '\u{A0}', '\u{85}', 'Ж', '日'] { assert_eq!(pdfdoc_byte(c), None, "{c}"); } - assert_eq!(legacy_candidates("Çok"), [vec![0xC7, b'o', b'k']]); - assert_eq!(legacy_candidates("şifre"), [b"ifre".to_vec(), "şifre".as_bytes().to_vec()]); + assert_eq!(legacy_candidates("Çok"), [Zeroizing::new(vec![0xC7, b'o', b'k'])]); + assert_eq!(legacy_candidates("şifre"), [Zeroizing::new(b"ifre".to_vec()), Zeroizing::new("şifre".as_bytes().to_vec())]); } diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index c4a39bf689..e269fda375 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -61,6 +61,15 @@ impl PageImage { self.clips.iter().any(|clip| clip.app_crop) } + /// The editing frame, respecting explicit PDF/editor crops but not the page boundary. + /// Selected handles remain reachable outside the page; body hits still use `contains`. + pub fn frame_rect(&self) -> Option<[f64; 4]> { + self.clips.iter().try_fold(self.rect, |rect, clip| { + let b = clip.path.bounding_box(); + intersection(rect, [b.x0, b.y0, b.x1, b.y1]) + }) + } + /// Whether a user-space point belongs to the placed artwork and every active clip. pub fn contains(&self, point: [f64; 2]) -> bool { if !point.iter().all(|v| v.is_finite()) { diff --git a/crates/edit/src/rules.rs b/crates/edit/src/rules.rs index 52600ab4dd..2457e2d7ff 100644 --- a/crates/edit/src/rules.rs +++ b/crates/edit/src/rules.rs @@ -26,6 +26,10 @@ pub fn page_rules(doc: &Document, page: usize) -> Result, EditErro let pages = pdfcraft_model::pages(doc); let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?; let streams = crate::text::content_streams(doc, &p.dict)?; + // Unread content can change the graphics state used by every later stream. + if streams.iter().any(|stream| stream.read != crate::text::Read::Whole) { + return Err(EditError::Invalid("page content could not be completely decoded; cannot extract its ruled lines safely".into())); + } let pieces = crate::text::joined(&streams); let ops = pieces.parse().ops; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d56240ee87..6c514515d0 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2109,6 +2109,229 @@ fn japanese_fallback_reports_the_face_after_save_and_reopen() { } } +#[test] +#[ignore = "manual mixed-script rewrite timing probe; run with --ignored --nocapture"] +fn mixed_script_paragraph_rewrite_timing_probe() { + assert!(pdfcraft_fonts::document_japanese_font().is_some(), "the timing probe requires the release-pinned craft-fonts input"); + for (repeats, width) in [(32, 480.0), (512, 480.0), (2048, 480.0), (512, 14_400.0)] { + let draft = "The quick brown fox шалом ".repeat(repeats); + let mut samples = Vec::new(); + for _ in 0..3 { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (The quick brown fox) Tj ET"); + let style = text::BlockStyle { + width: Some(width), + align: Some(added::Align::Center), + underline: Some(true), + char_spacing: Some(1.25), + scale: Some(80.0), + ..Default::default() + }; + let start = std::time::Instant::now(); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap(); + samples.push(start.elapsed().as_micros()); + assert!(text::text_lines(&doc, 0).unwrap().iter().any(|line| line.base_font == "Helvetica")); + std::hint::black_box(doc); + } + samples.sort_unstable(); + eprintln!("mixed-script-rewrite bytes={} width={width} median_us={} samples_us={samples:?}", draft.len(), samples[1]); + } +} + +#[test] +fn mixed_script_source_measurements_are_bounded_by_distinct_characters() { + if without_craft_fonts("mixed_script_source_measurements_are_bounded_by_distinct_characters") { + return; + } + for resource in ["F1", "F2"] { + for repeats in [1, 256] { + for width in [80.0, 14_400.0] { + let draft = "ab шалом ab ".repeat(repeats); + let distinct = draft.chars().collect::>().len(); + let mut doc = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET")); + let style = text::BlockStyle { width: Some(width), align: Some(added::Align::Justify), underline: Some(true), ..Default::default() }; + text::take_source_character_measurements(); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap(); + let measured = text::take_source_character_measurements(); + assert_eq!(measured, distinct, "{resource}, {repeats} repeats, width {width}: measure each distinct source candidate once"); + } + } + } +} + +#[test] +fn mixed_script_cached_widths_follow_each_rewrite_state() { + if without_craft_fonts("mixed_script_cached_widths_follow_each_rewrite_state") { + return; + } + let draft = "ab шалом ab шалом ab шалом ab"; + for (resource, size, spacing, scale) in [("F2", 12.0, 0.0, 100.0), ("F1", 18.0, 1.25, 80.0), ("F2", 18.0, 1.25, 80.0), ("F1", 12.0, 0.0, 100.0)] { + let mut doc = text_page(&format!("BT /{resource} 12 Tf 5 Tw 72 700 Td (ab) Tj ET")); + let original_font = doc.get(pdfcraft_cos::ObjRef::new(if resource == "F1" { 5 } else { 6 }, 0)).as_ref().clone(); + let style = text::BlockStyle { + size: Some(size), + width: Some(120.0), + align: Some(added::Align::Center), + underline: Some(true), + char_spacing: Some(spacing), + scale: Some(scale), + ..Default::default() + }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + let doc = reopen(&doc); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(if resource == "F1" { 5 } else { 6 }, 0)).as_ref(), &original_font); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text.split_whitespace().collect::>(), draft.split_whitespace().collect::>()); + let page = pdfcraft_model::pages(&doc).remove(0); + let fonts = doc.dict(doc.dict(page.dict.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap(); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let underlines: Vec<_> = ops.iter().filter(|op| op.is("m") || op.is("l")).collect(); + let rows: Vec<_> = lines.chunk_by(|a, b| (a.origin_baseline() - b.origin_baseline()).abs() < 0.001).collect(); + assert!(rows.len() > 1, "the cache must also be used while wrapping"); + assert_eq!(underlines.len(), rows.len() * 2); + for (row, underline) in rows.into_iter().zip(underlines.as_chunks::<2>().0) { + // Recompute from the saved fonts, independently of the rewrite's cache. + let widths: Vec<_> = row + .iter() + .map(|line| { + if line.text.chars().any(|ch| ch.is_ascii_alphabetic()) { + assert_eq!(line.font, resource); + } + let m = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, &doc.dict(fonts.get(line.font.as_bytes()).unwrap()).unwrap()); + let bytes = m.encode(&line.text).unwrap_or_else(|| panic!("saved font {} cannot encode {line:?}", m.base_font)); + m.codes(&bytes).iter().map(|(code, _)| m.width(*code) * size + spacing).sum::() * scale / 100.0 + }) + .collect(); + let total: f64 = widths.iter().sum(); + let left = (120.0 - total).max(0.0) / 2.0; + let mut x = 72.0 + left; + for (line, advance) in row.iter().zip(widths) { + assert!((line.baseline()[0] - x).abs() < 0.02, "{resource}, size {size}: {line:?}, expected {x}"); + x += advance; + } + assert!((underline[0].operands[0].as_f64().unwrap() - left).abs() < 0.02); + assert!((underline[1].operands[0].as_f64().unwrap() - left - total).abs() < 0.02); + } + } +} + +#[test] +fn mixed_script_paragraph_keeps_the_source_font_without_an_explicit_line_break() { + if without_craft_fonts("mixed_script_paragraph_keeps_the_source_font_without_an_explicit_line_break") { + return; + } + let original = "The quick brown fox jumps over the lazy dog"; + let ending = "and keeps running through the long field."; + let draft = format!("{original} шалом {ending}"); + let mut doc = text_page(&format!("BT /F1 12 Tf 14.4 TL 72 700 Td ({original}) Tj T* ({ending}) Tj ET")); + let font = doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_ref().clone(); + text::replace_block(&mut doc, 0, 0, &draft).unwrap(); + let doc = reopen(&doc); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_ref(), &font); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.iter().any(|l| l.base_font == "Helvetica" && l.text.contains("The quick brown fox")), "{lines:#?}"); + for line in &lines { + if line.text.chars().any(|ch| ch.is_ascii_alphabetic()) { + assert_eq!(line.base_font, "Helvetica", "Latin must retain its source font: {line:?}"); + } + } + assert!(lines.iter().any(|l| l.text.contains("шалом") && l.base_font != "Helvetica")); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text.split_whitespace().collect::>(), draft.split_whitespace().collect::>()); + let fallback = page_font_programs(&doc).into_iter().filter(|(subtype, _)| subtype == b"Type3").count(); + assert_eq!(fallback, 1, "the new letters share one fallback font"); +} + +#[test] +fn mixed_script_subset_spaces_remain_extractable_after_saving() { + if without_craft_fonts("mixed_script_subset_spaces_remain_extractable_after_saving") { + return; + } + for (resource, draft) in [("F2", "ab шалом ab"), ("F2", "шалом ab"), ("F2", "ab шалом"), ("F1", "ab шалом ab")] { + for align in [added::Align::Left, added::Align::Center, added::Align::Right] { + let mut doc = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET")); + let font_id = pdfcraft_cos::ObjRef::new(if resource == "F2" { 6 } else { 5 }, 0); + let original_font = doc.get(font_id).as_ref().clone(); + let style = + text::BlockStyle { width: Some(300.0), align: Some(align), char_spacing: Some(1.25), scale: Some(80.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + for bytes in [write_full(&doc, &Default::default()).unwrap(), pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap()] { + let saved = Document::open(Arc::new(bytes)).unwrap(); + let lines = text::text_lines(&saved, 0).unwrap(); + let decoded: String = lines.iter().map(|line| line.text.as_str()).collect(); + assert_eq!(decoded, draft, "{resource}: edge spaces of source runs must be real extractable glyphs"); + assert_eq!(text::text_blocks(&saved, 0).unwrap()[0].text, draft); + assert_eq!(saved.get(font_id).as_ref(), &original_font); + for line in lines.iter().filter(|line| line.text.chars().any(|ch| ch.is_ascii_alphabetic())) { + assert_eq!(line.font, resource, "the Latin letters retain their source font: {line:?}"); + } + } + } + } +} + +/// Mixed-font fallback must preserve the refusal of unsupported per-row alignment. +#[test] +fn mixed_script_row_alignment_is_refused_without_changes() { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Latin text) Tj ET"); + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + let style = text::BlockStyle { width: Some(300.0), aligns: vec![Some(added::Align::Center)], ..Default::default() }; + let err = text::rewrite_block(&mut doc, 0, 0, Some("abcшаломdef"), &style).unwrap_err(); + assert!(err.to_string().contains("selected-row alignment"), "{err}"); + assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before); +} + +#[test] +fn mixed_script_runs_use_source_and_fallback_advances_for_alignment() { + if without_craft_fonts("mixed_script_runs_use_source_and_fallback_advances_for_alignment") { + return; + } + for (resource, prefix, suffix) in [("F1", "abc", "def"), ("F2", "ab", "ab"), ("F1", "abc ", "def")] { + // Rewriting normalizes Tw; measuring a source space must use that same state. + let mut doc = text_page(&format!("BT /{resource} 12 Tf 5 Tw 72 700 Td ({prefix}) Tj ET")); + let draft = format!("{prefix}шалом{suffix}"); + let style = text::BlockStyle { + width: Some(300.0), + align: Some(added::Align::Center), + char_spacing: Some(1.25), + scale: Some(80.0), + ..Default::default() + }; + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap(); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, draft); + assert_eq!((lines[0].font.as_str(), lines[2].font.as_str()), (resource, resource)); + let page = pdfcraft_model::pages(&doc).remove(0); + let resources = doc.dict(page.dict.get(b"Resources").unwrap()).unwrap(); + let fonts = doc.dict(resources.get(b"Font").unwrap()).unwrap(); + let advances: Vec = lines + .iter() + .zip([prefix, "шалом", suffix]) + .map(|(line, part)| { + let font = doc.dict(fonts.get(line.font.as_bytes()).unwrap()).unwrap(); + let m = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, &font); + let bytes = m.encode(part).unwrap(); + (m.codes(&bytes).iter().map(|(c, _)| m.width(*c) * 12.0).sum::() + part.chars().count() as f64 * 1.25) * 0.8 + }) + .collect(); + let mut x = 72.0 + (300.0 - advances.iter().sum::()) / 2.0; + for (line, width) in lines.iter().zip(advances) { + assert!((line.baseline()[0] - x).abs() < 0.01, "{resource}: {line:?}, expected {x}"); + x += width; + } + } +} + +#[test] +fn mixed_script_paragraph_batches_only_missing_glyphs_and_refuses_atomically() { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Latin text) Tj ET"); + // Cyrillic also uses bundled Inter in builds without craft-fonts. + text::rewrite_block_with_limit(&mut doc, 0, 0, Some("Latin шалом text"), &Default::default(), 2).unwrap(); + assert_eq!(page_font_programs(&doc).iter().filter(|(subtype, _)| subtype == b"Type3").count(), 3); + let before = write_full(&doc, &Default::default()).unwrap(); + assert!(text::replace_block(&mut doc, 0, 0, "Latin \u{FDFD} text").is_err()); + assert_eq!(write_full(&doc, &Default::default()).unwrap(), before); +} + #[test] fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() { if without_craft_fonts("cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it") { @@ -2127,7 +2350,33 @@ fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() { } else { text::replace_line(&mut doc, 0, 0, replacement).unwrap(); } - assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement); + let doc = reopen(&doc); + if paragraph { + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, replacement); + assert!(text::text_lines(&doc, 0).unwrap().iter().any(|l| l.text.contains("world") && l.base_font == "Times-Roman")); + } else { + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, replacement); + } + } +} + +/// A subset missing spaces must still split source and fallback runs while aligning text. +#[test] +fn mixed_source_subset_without_space_keeps_fallback_runs_after_reopen() { + let draft = "a қ b a қ b"; + for align in [added::Align::Left, added::Align::Center, added::Align::Justify] { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let original = doc.get(pdfcraft_cos::ObjRef::new(6, 0)).as_ref().clone(); + let style = text::BlockStyle { width: Some(36.0), align: Some(align), char_spacing: Some(1.25), scale: Some(80.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, draft); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(6, 0)).as_ref(), &original); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.iter().any(|line| line.text.contains('a') && line.font == "F2")); + assert!(lines.iter().any(|line| line.text.contains('b') && line.font == "F2")); + assert!(lines.iter().any(|line| line.text.contains('қ') && line.base_font == "Inter-Regular")); + assert!(lines.iter().any(|line| line.text.contains(' ') && line.base_font == "Inter-Regular")); } } @@ -2144,9 +2393,22 @@ fn uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face() { assert_eq!(edit.substituted.as_deref(), Some("Inter Type3"), "{replacement}: {edit:?}"); let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); assert!(content.contains("/PCLat"), "{replacement}: {content}"); - let lines = text::text_lines(&reopen(&doc), 0).unwrap(); - assert_eq!(lines[0].text, replacement); - assert_eq!(lines[0].base_font, "Inter-Regular", "the generated font reports the face it drew"); + let reopened = reopen(&doc); + let lines = text::text_lines(&reopened, 0).unwrap(); + if paragraph { + assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement); + for ch in ['a', 'b'].into_iter().filter(|ch| replacement.contains(*ch)) { + assert!(lines.iter().any(|line| line.text.contains(ch) && line.base_font == "ABCDEF+Arial"), "{lines:?}"); + } + } else { + assert_eq!(lines[0].text, replacement); + } + for ch in replacement.chars().filter(|ch| !ch.is_ascii()) { + assert!( + lines.iter().any(|line| line.text.contains(ch) && line.base_font == "Inter-Regular"), + "the generated Inter face draws {ch}: {lines:?}" + ); + } } } } @@ -9684,6 +9946,102 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() { assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched"); } +/// `'` moves to the next line before it shows its text, and `"` also sets the word and character +/// spacing (ISO 32000-2 §9.4.3, Table 107). Rewriting a paragraph drawn with them drops the +/// operator but must keep what it did: text after it, positioned from that line or using that +/// spacing, stays exactly where and how it was. Both the single-style paragraph path and the +/// region path (a draft with a line break) are checked. +#[test] +fn rewriting_a_paragraph_drawn_with_quote_operators_keeps_their_line_move_and_spacing() { + let cases = [ + // The next line is placed relative to the line `'` moved to. + "BT /F1 12 Tf 14 TL 72 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET", + // The same with `"`, whose spacing the footer also inherits. + "BT /F1 12 Tf 14 TL 72 714 Td 3 2 (Target line here) \" 0 -100 Td (Footer stays put) Tj ET", + // Spacing set by `"` carries over into the next text object. + "BT /F1 12 Tf 14 TL 72 714 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET", + // A split text object: text before the paragraph stays first, then the `'` lines go. + "BT /F1 12 Tf 14 TL 72 760 Td (Heading words) Tj 0 -46 Td (Target line here) Tj (more target) ' 0 -100 Td (Footer stays put) Tj ET", + // Split at a `'`: the rewrite's `Tm` there is already past its line move. + "BT /F1 12 Tf 14 TL 72 760 Td (Heading words) Tj 0 -32 Td (Target line here) ' (more target) ' 0 -100 Td (Footer stays put) Tj ET", + ]; + for content in cases { + let footer = |doc: &Document| { + let lines = text::text_lines(doc, 0).unwrap(); + let f = lines.iter().find(|l| l.text == "Footer stays put").unwrap_or_else(|| panic!("no footer in {content}")); + (f.rect, f.ink.clone()) + }; + let original = text_page(content); + let before = footer(&original); + let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text.starts_with("Target")).unwrap(); + for draft in ["NEW TEXT", "NEW\nTEXT"] { + let mut doc = original.clone(); + text::replace_block(&mut doc, 0, target, draft).unwrap(); + let doc = reopen(&doc); + assert_eq!(footer(&doc), before, "{content} with {draft:?}"); + let texts: Vec = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert!(texts.iter().any(|t| t.contains("NEW")) && !texts.iter().any(|t| t.contains("Target")), "{content} with {draft:?}: {texts:?}"); + } + } +} + +/// [`text::replace_line`] also drops copies of the line drawn on the same baseline (a fake-bold +/// overprint). A copy drawn with `'` or `"` must leave that operator's line move and spacing +/// behind (ISO 32000-2 §9.4.3, Table 107), like the line's own first operator does: text after +/// it is positioned from that line or uses that spacing. Each copy is tried as the target, so the +/// one drawn with the quote operator is both the replaced line and the dropped copy. +#[test] +fn replacing_a_line_keeps_what_a_dropped_quote_copy_did() { + let cases = [ + // The copy's `'` moves to the line the footer is placed from. + "BT /F1 12 Tf 14 TL 72 700 Td (Target line here) Tj ET BT 72.3 714 Td (Target line here) ' 0 -100 Td (Footer stays put) Tj ET", + // The copy's `"` sets the spacing the footer inherits in the next text object. + "BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td 3 2 (Target line here) \" ET BT 72 600 Td (Footer stays put) Tj ET", + ]; + for content in cases { + let footer = |doc: &Document| { + let lines = text::text_lines(doc, 0).unwrap(); + let f = lines.iter().find(|l| l.text == "Footer stays put").unwrap_or_else(|| panic!("no footer in {content}")); + (f.rect, f.ink.clone()) + }; + let original = text_page(content); + let before = footer(&original); + let targets: Vec = + text::text_lines(&original, 0).unwrap().iter().enumerate().filter(|(_, l)| l.text == "Target line here").map(|(i, _)| i).collect(); + assert_eq!(targets.len(), 2, "{content}"); + for target in targets { + let mut doc = original.clone(); + text::replace_line(&mut doc, 0, target, "NEW TEXT").unwrap(); + let doc = reopen(&doc); + assert_eq!(footer(&doc), before, "{content} replacing line {target}"); + let texts: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert!(texts.iter().any(|t| t == "NEW TEXT") && !texts.iter().any(|t| t.contains("Target")), "{content}: {texts:?}"); + } + } +} + +/// A copy dropped with the line it overprints moved the pen by its text: a run after it in its +/// text object, placed by that pen, stays where it was (ISO 32000-2 §9.4.4). +#[test] +fn replacing_a_line_keeps_the_advance_of_a_dropped_copy() { + for content in [ + "BT /F1 12 Tf 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) Tj [-20000] TJ (Far) Tj ET", + "BT /F1 12 Tf 0 TL 72 700 Td (Target line here) Tj ET BT 72.3 700 Td (Target line here) ' [-20000] TJ (Far) Tj ET", + ] { + let original = text_page(content); + let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "Far").map(|l| l.rect); + let before = place(&original).unwrap_or_else(|| panic!("Far is a line of its own in {content}")); + // The line drawn first, so the one followed by Far is the copy. + let target = text::text_lines(&original, 0).unwrap().iter().position(|l| l.text == "Target line here").unwrap(); + let mut doc = original.clone(); + text::replace_line(&mut doc, 0, target, "NEW TEXT").unwrap(); + let doc = reopen(&doc); + let texts: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert!(!texts.iter().any(|t| t.contains("Target")), "{content}: the copy went too: {texts:?}"); + assert_eq!(place(&doc), Some(before), "{content}"); + } +} + /// #2512: a session's drafts are laid out from the document as it was opened, never from each /// other, and its commit writes what a one-shot rewrite writes, whatever was laid out before. #[test] @@ -12041,16 +12399,19 @@ fn variation_public_line_and_block_edits_preserve_selectors_after_reopen() { #[test] fn variation_public_selector_does_not_shift_centered_text_with_character_spacing() { // қ requires the same bundled fallback for both drafts, without optional font inputs. - for spacing in [0.0, 2.0] { - let mut plain = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); - let mut selected = plain.clone(); - let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() }; - text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap(); - text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap(); - let a = text::text_lines(&reopen(&plain), 0).unwrap(); - let b = text::text_lines(&reopen(&selected), 0).unwrap(); - assert_eq!(a[0].base_font, b[0].base_font); - assert!(close(a[0].rect, b[0].rect), "Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect); + // Exercise both retained source runs and a wholly substituted subset. + for resource in ["F1", "F2"] { + for spacing in [0.0, 2.0] { + let mut plain = text_page(&format!("BT /{resource} 12 Tf 72 700 Td (ab) Tj ET")); + let mut selected = plain.clone(); + let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() }; + text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap(); + text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap(); + let a = text::text_lines(&reopen(&plain), 0).unwrap(); + let b = text::text_lines(&reopen(&selected), 0).unwrap(); + assert_eq!(a[0].base_font, b[0].base_font); + assert!(close(a[0].rect, b[0].rect), "{resource}, Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect); + } } } /// Whole-paragraph alignment retains both soft wraps and typed hard breaks after save/reopen. @@ -13610,6 +13971,124 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() { } } +/// Each `Tj`/`TJ` moves the text matrix by the width of its text. A run shown later in the same +/// text object without a `Td` or `Tm` of its own (a raised run in another colour, a column after +/// a `TJ` gap) is placed by those advances. Rewriting the paragraph before it must keep them, as +/// deleting a paragraph does: the run must not jump onto the new text. A kept run before the +/// paragraph splits the text object, and what reopens it keeps that run's advance too. +#[test] +fn rewriting_a_paragraph_keeps_later_runs_on_its_baseline_in_place() { + let cases = [ + ("BT /F1 12 Tf 72 700 Td (Target words) Tj 1 0 0 rg 3 Ts (Red) Tj ET", "Red"), + ("BT /F1 12 Tf 72 700 Td (Target words) Tj [-20000] TJ (Far) Tj ET", "Far"), + // Several dropped runs: each one's advance is kept. + ("BT /F1 12 Tf 72 700 Td (Target ) Tj (wor) Tj (ds) Tj [-20000] TJ (Far) Tj ET", "Far"), + // A kept run before the paragraph on its baseline: the text object is split there, and + // the pen it had reached is kept as well as the line matrix. + ("BT /F1 12 Tf 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj ET", "Far"), + ("BT /F1 12 Tf 72 700 Td (Left) Tj [-20000] TJ (Target ) Tj (words) Tj [-20000] TJ (Far) Tj ET", "Far"), + // The same with horizontal scaling and a later line placed from the line matrix. + ("BT /F1 12 Tf 80 Tz 14 TL 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj T* (Next) Tj ET", "Far"), + ("BT /F1 12 Tf 80 Tz 14 TL 72 700 Td (Left) Tj [-20000] TJ (Target words) Tj [-20000] TJ (Far) Tj T* (Next) Tj ET", "Next"), + // A `Q` puts back the text matrix its `q` saved: the `Tm` between them doesn't place `Far`. + ("BT /F1 12 Tf 72 700 Td (Target words) Tj q 1 0 0 1 300 300 Tm Q [-20000] TJ (Far) Tj ET", "Far"), + // A `Q` with nothing saved restores nothing: the advance is still kept. + ("BT /F1 12 Tf 72 700 Td (Target words) Tj Q [-20000] TJ (Far) Tj ET", "Far"), + ]; + for (content, run) in cases { + let original = text_page(content); + let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == run).map(|l| l.rect); + let before = place(&original).unwrap_or_else(|| panic!("{run} is a line of its own in {content}")); + let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text == "Target words").unwrap(); + for draft in ["NEW TEXT", "NEW\nTEXT"] { + let mut doc = original.clone(); + text::replace_block(&mut doc, 0, target, draft).unwrap(); + let after = place(&reopen(&doc)).unwrap_or_else(|| panic!("{run} is gone: {content} with {draft:?}")); + assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-6), "{content} with {draft:?}: {before:?} -> {after:?}"); + } + } +} + +/// Vertical text advances down the column (ISO 32000-2 §9.4.4): runs dropped by a rewrite leave +/// that advance, so a run later in the same text object stays where it was. +#[test] +fn rewriting_vertical_runs_keeps_a_later_run_in_its_column_in_place() { + // The second with a horizontal scaling (`Tz`), which a vertical advance doesn't take. + for content in [ + "BT /V1 20 Tf 500 720 Td <0041> Tj <0042> Tj [-8000] TJ <0043> Tj ET", + "BT /V1 20 Tf 80 Tz 500 720 Td <0041> Tj <0042> Tj [-8000] TJ <0043> Tj ET", + ] { + vertical_runs_keep_a_later_run_in_place(content); + } +} + +fn vertical_runs_keep_a_later_run_in_place(content: &str) { + let original = vertical_page(content); + let place = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "C").map(|l| l.rect); + let before = place(&original).unwrap_or_else(|| panic!("C is a line of its own: {:?}", text::text_lines(&original, 0))); + let target = text::text_blocks(&original, 0).unwrap().iter().position(|b| b.text == "AB").unwrap(); + for draft in [ + "D", "D +E", + ] { + let mut doc = original.clone(); + text::replace_block(&mut doc, 0, target, draft).unwrap(); + let after = place(&reopen(&doc)).unwrap_or_else(|| panic!("C is gone with {draft:?}")); + assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-6), "{content} with {draft:?}: {before:?} -> {after:?}"); + } +} + +/// The advances read the glyphs without `/ActualText`, which can come to more text than the +/// reading the edit was laid out from. Past the page's text budget that way, no advance is kept +/// and the edit goes ahead, rather than refusing a page the reading allowed. +#[test] +fn dropped_advances_past_the_text_budget_keep_none_rather_than_refuse() { + let doc = text_page("BT /F1 12 Tf 72 700 Td (Drop) Tj [-20000] TJ (Far) Tj ET"); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let drop: std::collections::HashSet = ops.iter().position(|o| o.is("Tj")).into_iter().collect(); + let page = pdfcraft_model::pages(&doc)[0].dict.clone(); + let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + assert_eq!(text::dropped_advances_within(&doc, &ops, &res, &drop, None).len(), 1, "the advance Far relies on"); + assert!(text::dropped_advances_within(&doc, &ops, &res, &drop, Some(2)).is_empty(), "too much text to read: none"); +} + +#[test] +fn page_rules_refuse_unread_graphics_state() { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("UnsupportedFilter")); + let unread = Stream::from_raw(dict, b"2 0 0 2 0 0 cm".to_vec()); + let damaged = damaged_flate(b"0 g\n", b"2 0 0 2 0 0 cm"); + for stream in [unread, damaged] { + let mut doc = image_page_with("10 10 m 100 10 l S"); + let incomplete = doc.add(Object::Stream(stream)); + doc.update_dict(ObjRef::new(3, 0), |page| { + page.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(incomplete), Object::Ref(ObjRef::new(4, 0))])); + }) + .unwrap(); + assert!(page_rules(&doc, 0).is_err(), "later geometry cannot be trusted after unread graphics state"); + } + assert_eq!(page_rules(&image_page_with("10 10 m 100 10 l S"), 0).unwrap(), [[10.0, 10.0, 100.0, 10.0]]); +} + +#[test] +fn image_editing_frame_keeps_page_edge_outside_explicit_clips() { + for (prefix, expected) in [ + ("", [-30.0, -20.0, 70.0, 80.0]), + ("-10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]), + ("/PdfCraftImageCrop MP -10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]), + ] { + let doc = image_page_with(&format!("q {prefix}100 0 0 100 -30 -20 cm /Im0 Do Q")); + for doc in [&doc, &reopen(&doc)] { + let im = images::page_images(doc, 0).unwrap().remove(0); + assert_eq!(im.frame_rect(), Some(expected)); + assert_eq!(im.has_editor_crop(), prefix.starts_with("/PdfCraftImageCrop")); + assert_eq!(im.visible_rect.unwrap()[0..2], [0.0, 0.0]); + assert!(!im.contains([-5.0, 10.0]), "out-of-page pixels stay unselectable"); + assert!(im.contains([10.0, 10.0])); + } + } +} + #[test] fn explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_font() { let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET"); @@ -14277,6 +14756,29 @@ fn legacy_thai_subsets_are_read_within_one_budget_per_edit() { assert_eq!(pdfcraft_cos::write_full(&distinct, &Default::default()).unwrap(), before, "a refused edit changes nothing"); } +/// An edit whose THSarabunPSK subset spends the edit's allowance to the byte goes through: only a +/// read refused for want of it refuses the edit. The subset is read four times over its program +/// (the edit's metrics read, `LegacyThaiFont`'s, the editor's program read and its metrics read) +/// and three times over its ToUnicode, so a program 192 bytes short of 16 MiB and a ToUnicode of +/// 256 bytes cost exactly the 64 MiB; a byte more of ToUnicode is three past it. +#[test] +fn a_thai_edit_that_spends_its_allowance_to_the_byte_goes_through() { + let mut program = legacy_thai_program(); + program.resize((16 << 20) - 192, 0); + let edit = |unicode_len: usize| { + let mut unicode = LEGACY_THAI_UNICODE.to_vec(); + unicode.resize(unicode_len, b' '); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &program, &unicode); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).map(|_| ()).map_err(|e| e.to_string()) + }; + assert_eq!(edit(256), Ok(())); + let error = edit(257).unwrap_err(); + assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}"); +} + /// A THSarabunPSK-named subset that has OpenType Thai mark positioning is a modern face: it is /// shaped with its own layout like any source font, not by the legacy shaper (which would ask /// for Windows PUA alternates such a face doesn't need). diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index ada90d8484..ac65b83cbe 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -10,7 +10,7 @@ //! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted. //! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; @@ -45,19 +45,21 @@ pub use blocks::{ use blocks::{kept_words, paragraph_separator, row_break}; pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts}; use editor::{builtin_thai_choice, layout_draft}; -#[cfg(test)] -pub(crate) use fallback::SOURCE_SPACE_CALLS; use fallback::{ MAX_TYPE3_GLYPHS, Type3Glyph, fallback_face, needs_type3, no_fallback_font, no_glyph_for, same_base_font, same_font, source_encodes, source_family, source_program, source_show, source_space, source_width, type3_faces, type3_glyph, write_type3_glyphs, }; #[cfg(test)] +pub(crate) use fallback::{SOURCE_SPACE_CALLS, take_source_character_measurements}; +#[cfg(test)] use fallback::{add_type3_font, is_type3_fallback}; pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name}; use interp::{Origin, Paint, union}; +#[cfg(test)] +pub(crate) use lines::dropped_advances_within; pub(crate) use lines::{ - ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, first_op_after_damage, form_call, - joined, resource_dict, rewritten_contents, splice, + ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, dropped_advances, + dropped_side_effects, first_op_after_damage, form_call, joined, reopen_text_object, resource_dict, rewritten_contents, splice, }; pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines}; use lines::{page_dict, word_lines}; diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index b714bcd592..3ab0240f54 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -1,5 +1,5 @@ use super::interp::{Carry, interpret}; -use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, splice, text_lines}; +use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, show_side_effects, splice, text_lines}; use super::rewrite::{LineEdit, coincident_ops, rewrite_block}; use super::{Document, EditError, HashMap, Object, Op, TextLine, group_blocks, regions}; @@ -315,13 +315,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), } let mut replacement = Vec::new(); if let Some(op) = ops.get(s.op) { - if op.is("\"") { - replacement.push(Op::new("Tw", vec![op.operands.first().cloned().unwrap_or(Object::Int(0))])); - replacement.push(Op::new("Tc", vec![op.operands.get(1).cloned().unwrap_or(Object::Int(0))])); - } - if op.is("'") || op.is("\"") { - replacement.push(Op::new("T*", vec![])); - } + replacement.extend(show_side_effects(op)); } replacement.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])])); patches.insert(s.op, replacement); diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index f7c795ca65..d186548fcb 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -1,3 +1,4 @@ +use super::HashSet; use super::interp::Ts; use super::lines::page_dict; use super::{ @@ -630,6 +631,21 @@ pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option(mut text: &'a str, source_chars: Option<&'a HashSet>) -> impl Iterator + 'a { + std::iter::from_fn(move || { + let first = text.chars().next()?; + let source = source_chars.is_some_and(|chars| chars.contains(&first)); + let end = source_chars + .and_then(|chars| text.char_indices().find(|(_, ch)| chars.contains(ch) != source).map(|(offset, _)| offset)) + .unwrap_or(text.len()); + // char_indices supplies UTF-8 boundaries; get also handles an invalid range safely. + let run = text.get(..end)?; + text = text.get(end..)?; + Some((source, run)) + }) +} + /// The source font's own TrueType/OpenType program, when the document embeds a complete one whose /// licence allows editable embedding. A subset (an `ABCDEF+` name) is left out: a character can keep /// its mapping there after the subsetter dropped its outline. @@ -702,6 +718,25 @@ pub(crate) fn source_width(metrics: &Metrics, text: &str, size: f64, space: f64, words + text.matches(' ').count() as f64 * (space * size + char_spacing + word_spacing) } +#[cfg(test)] +thread_local! { + static SOURCE_CHARACTER_MEASUREMENTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn take_source_character_measurements() -> usize { + SOURCE_CHARACTER_MEASUREMENTS.with(|count| count.replace(0)) +} + +/// An actually encodable source character in the normalized `Tw = 0` drawing state. +pub(super) fn source_character_advance(metrics: &Metrics, ch: char, size: f64, char_spacing: f64) -> Option { + #[cfg(test)] + SOURCE_CHARACTER_MEASUREMENTS.with(|count| count.set(count.get().saturating_add(1))); + let mut utf8 = [0; 4]; + let bytes = metrics.encode(ch.encode_utf8(&mut utf8))?; + Some(metrics.codes(&bytes).iter().map(|(code, _)| metrics.width(*code) * size + char_spacing).sum()) +} + pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacing: f64, word_spacing: f64) -> Result, EditError> { if let Some(bytes) = metrics.encode(text) { return Ok(vec![Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])]); diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 9eadb65107..e1c4b2d312 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -115,6 +115,146 @@ pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut D Ok(out) } +/// What a text-showing operator does besides showing text (ISO 32000-2 §9.4.3, Table 107): `'` +/// moves to the next line (`T*`), and `"` first sets the word and character spacing (`aw Tw`, +/// `ac Tc`). Text after it relies on both, so a dropped `'` or `"` leaves these in its place. +pub(crate) fn show_side_effects(op: &Op) -> Vec { + let mut out = Vec::new(); + if op.is("\"") { + out.extend(op.num(0).map(|aw| Op::new("Tw", vec![pdfcraft_content::num(aw)]))); + out.extend(op.num(1).map(|ac| Op::new("Tc", vec![pdfcraft_content::num(ac)]))); + } + if op.is("'") || op.is("\"") { + out.push(Op::new("T*", vec![])); + } + out +} + +/// For a paragraph or region rewrite: what dropped operator `i` must leave behind +/// ([`show_side_effects`]), so the text after it keeps its line and spacing. `split_at` is the +/// operator the text object was split at: the rewrite's `Tm` and state there already include +/// that operator's effects. +pub(crate) fn dropped_side_effects(ops: &[Op], i: usize, keep: bool, split_at: Option) -> Vec { + if keep || split_at == Some(i) { + return Vec::new(); + } + ops.get(i).map(show_side_effects).unwrap_or_default() +} + +/// For a paragraph or region rewrite that splits a text object: what reopens it after the +/// rewritten paragraph, so the text after it starts where it did. `tlm` and `tm` are the line and +/// text matrices where the paragraph began, and `size` and `scale` the font size (`Tf`) and +/// horizontal scaling (`Tz` / 100) in force there. A `Tm` restores the line matrix, which a later +/// `Td` or `T*` moves from; a kept run before the paragraph on the same baseline had already +/// advanced the pen beyond it (ISO 32000-2 §9.4.4), so a numeric `TJ` then moves the text matrix +/// alone by that advance. Only an advance along a horizontal baseline is expressible that way; +/// one that isn't (a vertical run's, or one at a zero size) leaves the pen at the line matrix. +pub(crate) fn reopen_text_object(tlm: [f64; 6], tm: [f64; 6], size: Option, scale: f64) -> Vec { + let n = pdfcraft_content::num; + let mut out = vec![Op::new("BT", vec![]), Op::new("Tm", tlm.iter().map(|v| n(*v)).collect())]; + // tm = [1 0 0 1 tx ty] x tlm: solve for the advance (tx, ty) in text space. + let (dx, dy) = (tm[4] - tlm[4], tm[5] - tlm[5]); + let det = tlm[0] * tlm[3] - tlm[1] * tlm[2]; + let tx = (dx * tlm[3] - dy * tlm[2]) / det; + let ty = (dy * tlm[0] - dx * tlm[1]) / det; + let denominator = size.unwrap_or(0.0) * scale; + let adjustment = -1000.0 * tx / denominator; + if tx.is_finite() && ty.is_finite() && tx.abs() > 1e-9 && ty.abs() <= 1e-9 && denominator != 0.0 && adjustment.is_finite() { + out.push(Op::new("TJ", vec![Object::Array(vec![n(adjustment)])])); + } + out +} + +/// For a paragraph or region rewrite: a numeric `TJ` per dropped show operator in `drop`, moving +/// the text matrix as far as that operator's text did without drawing anything (as +/// [`super::clear_block`] does). Text shown later in the same text object without its own `Td` +/// or `Tm`, such as a run in another style or column on the same baseline, then stays where it +/// was. Only an advance such a run relies on is kept: none when the text object ends, or its next +/// kept run starts a line of its own (`Td`, `TD`, `Tm`, `T*`, `'`, `"`), first. An advance a `TJ` +/// can't express (spacing at a zero font size) is left out. `resources` is the page's resource +/// dictionary. This reads the glyphs without `/ActualText`, which can come to more text than the +/// reading the edit was laid out from; a page whose text that way exceeds the page's text budget +/// keeps no advances rather than refusing an edit the reading allowed. +pub(crate) fn dropped_advances(doc: &Document, ops: &[Op], resources: &Dict, drop: &std::collections::HashSet) -> HashMap { + dropped_advances_within(doc, ops, resources, drop, None) +} + +/// [`dropped_advances`] with `text_left` in place of the page's text budget (tests make it small). +pub(crate) fn dropped_advances_within( + doc: &Document, + ops: &[Op], + resources: &Dict, + drop: &std::collections::HashSet, + text_left: Option, +) -> HashMap { + let mut out = HashMap::new(); + let mut carry = Carry::new(); + if let Some(left) = text_left { + carry.text_left = left; + } + let follows = kept_run_follows(ops, drop); + let Ok(shown) = interpret(doc, ops, resources, &mut HashMap::new(), &mut carry, None, None) else { + return out; + }; + for s in shown { + if !drop.contains(&s.op) || s.advance == 0.0 || !follows.get(s.op).copied().unwrap_or(false) { + continue; + } + let denominator = s.state.size * s.state.scale; + let adjustment = -1000.0 * s.advance / denominator; + if denominator != 0.0 && adjustment.is_finite() { + out.entry(s.op).or_insert_with(|| Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])])); + } + } + out +} + +/// For each operator: whether a run shown after it in its text object, and not in `drop`, is +/// placed by the pen it left rather than by a line move of its own (see [`dropped_advances`]). +/// A `Q` puts back the text matrices its `q` saved (as [`interpret`] reads it), so a line move +/// between them doesn't count for the text after the `Q`. Leaving an advance in is harmless where +/// nothing turns out to use it. One pass from the end, so a page of many dropped runs stays linear. +fn kept_run_follows(ops: &[Op], drop: &std::collections::HashSet) -> Vec { + // The `q` and `Q` that pair up: [`interpret`] ignores a `Q` with nothing saved, so only a + // `Q` that restores something is a boundary here. + let mut paired = vec![false; ops.len()]; + let mut open: Vec = Vec::new(); + for (i, op) in ops.iter().enumerate() { + if op.is("q") { + open.push(i); + } else if op.is("Q") + && let Some(q) = open.pop() + { + for at in [q, i] { + if let Some(slot) = paired.get_mut(at) { + *slot = true; + } + } + } + } + let mut out = vec![false; ops.len()]; + let mut follows = false; + // What followed each `Q` still open (read backwards), restored at its `q`. + let mut after_restore: Vec = Vec::new(); + for (i, op) in ops.iter().enumerate().rev() { + if let Some(slot) = out.get_mut(i) { + *slot = follows; + } + let pairs = paired.get(i).copied().unwrap_or(false); + match op.op.as_slice() { + b"Q" if pairs => { + after_restore.push(follows); + follows = false; + } + b"q" if pairs => follows = follows || after_restore.pop().unwrap_or(false), + b"ET" | b"BT" | b"Td" | b"TD" | b"Tm" | b"T*" | b"'" | b"\"" => follows = false, + b"Tj" | b"TJ" if !drop.contains(&i) => follows = true, + _ => {} + } + } + out +} + /// The page's streams joined into the one stream they are: an operator may span two of them. pub(crate) fn joined(streams: &[ContentStream]) -> Pieces { Pieces::join(&streams.iter().map(|s| s.data.as_slice()).collect::>()) diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index c146ffcbc0..398396a53b 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,13 +1,17 @@ use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap}; use super::fallback::{ Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font, - source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, + source_character_advance, source_encodes, source_fallback_runs, source_family, source_program_completes, source_show, source_space, source_width, + type3_encode, type3_font, }; use super::interp::{Carry, Origin, TextState, interpret}; -use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines}; +use super::lines::{ + content_streams, dropped_advances, dropped_side_effects, joined, page_dict, reopen_text_object, rewritten_contents, show_side_effects, splice, + text_lines, +}; use super::{ - Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, is_centred, - parse, rewrite_changed_words, rewrite_styled_block, + Dict, Document, EditError, HashMap, HashSet, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, + is_centred, parse, rewrite_changed_words, rewrite_styled_block, }; /// What replacing a line did. @@ -68,16 +72,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let reused = font.as_ref().filter(|m| source_encodes(m, &shown)); let mut substituted = None; let mut replacement: Vec = Vec::new(); - // ' and " also move to the next line; keep that. - match ops[first].op.as_slice() { - b"'" => replacement.push(Op::new("T*", vec![])), - b"\"" => { - replacement.push(Op::new("Tw", vec![ops[first].operands.first().cloned().unwrap_or(Object::Int(0))])); - replacement.push(Op::new("Tc", vec![ops[first].operands.get(1).cloned().unwrap_or(Object::Int(0))])); - replacement.push(Op::new("T*", vec![])); - } - _ => {} - } + // ' and " also move to the next line (and " sets the spacing); keep that. + replacement.extend(ops.get(first).map(show_side_effects).unwrap_or_default()); match reused { Some(metrics) => replacement.extend(source_show( metrics, @@ -134,8 +130,23 @@ 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. - let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); + // Rebuild: the line's first operator becomes the replacement, its others go. Each other + // dropped `'` or `"` (a later run, or a copy on the same baseline) leaves its line move and + // spacing behind, as the first one does inside the replacement, and a dropped copy its advance + // where text after it is placed by that (the line's own runs give way to the replacement's). + let mut advances = dropped_advances(doc, &ops, &res, &drops); + for i in &target.ops { + advances.remove(i); + } + let new_data = splice(&pieces, &ops, |i| { + if i == first { + return (std::mem::take(&mut replacement), false); + } + let keep = !drops.contains(&i); + let mut insert = dropped_side_effects(&ops, i, keep, None); + insert.extend(advances.remove(&i)); + (insert, keep) + }); let contents = rewritten_contents(doc, &streams, new_data)?; let page_ref = p.obj; if substituted.is_some() { @@ -344,6 +355,7 @@ fn turned_line_width(first: &TextLine, crop: [f64; 4], offset: [f64; 2], width: /// The widths of the paragraph's text in the font it will be written in, in user space. struct Advance<'a> { + partial_advances: &'a HashMap>, /// The source font, when its glyphs are reused. source: Option<&'a Metrics>, type3: Option<&'a Type3Fallback>, @@ -370,11 +382,24 @@ impl Advance<'_> { (None, Some(fallback)) => { // Each character's count in `s`, from one pass over `s`. let mut counts: HashMap = HashMap::new(); + let mut source_advance = 0.0; + let mut fallback_count = 0usize; for c in s.chars() { - let count = counts.entry(c).or_insert(0); - *count = count.saturating_add(1); + // The selector is emitted with zero spacing, so it cannot widen the line. + if is_variation_selector(c) { + continue; + } + if let Some(width) = self.partial_advances.get(&c).copied().flatten() { + source_advance += width; + } else { + let count = counts.entry(c).or_insert(0); + *count = count.saturating_add(1); + fallback_count = fallback_count.saturating_add(1); + } } - fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::() + extra + source_advance + + fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::() + + fallback_count as f64 * self.char_spacing } (None, None) => self.family.std14(self.bold, self.italic).text_width(s, self.size) + extra, }; @@ -541,6 +566,8 @@ impl Alignment { /// Everything the wrapped lines of one paragraph are written with. struct LineWriter<'a> { + source_name: &'a str, + partial_chars: Option<&'a HashSet>, advance: &'a Advance<'a>, alignment: Alignment, encode: &'a Encoder, @@ -649,7 +676,8 @@ impl LineWriter<'_> { } // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). let visual = crate::bidi::to_visual(shown); - if let Some(metrics) = self.source.filter(|m| m.encode(" ").is_none()) { + // Partial reuse must reach show_piece: its missing spaces and glyphs belong to the fallback. + if let Some(metrics) = self.source.filter(|m| self.partial_chars.is_none() && m.encode(" ").is_none()) { let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual }; ops.extend(source_show(metrics, &indic_line, self.size, self.space, self.char_spacing, tw + stretch)?); } else { @@ -670,24 +698,30 @@ impl LineWriter<'_> { fn show_piece(&self, piece: &str, line: &str, tw: f64, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(), EditError> { let n = pdfcraft_content::num; - if let Some(metrics) = self.source { - let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; - ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?); - return Ok(()); - } - for run in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if run.name != cursor.shown_font { - ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)])); - cursor.shown_font = run.name; - } - if let Some(tc) = run.spacing { - // A variation selector's empty glyph: shown at spacing zero, then the paragraph's - // own spacing back, so it adds no advance. - ops.push(Op::new("Tc", vec![n(tc)])); - ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); - ops.push(Op::new("Tc", vec![n(self.char_spacing)])); + for (source, run) in source_fallback_runs(piece, self.partial_chars) { + if let Some(metrics) = self.source.filter(|_| self.partial_chars.is_none() || source) { + if cursor.shown_font != self.source_name { + ops.push(Op::new("Tf", vec![Object::name(self.source_name), n(self.size)])); + cursor.shown_font = self.source_name.to_owned(); + } + let indic_piece = if metrics.visual_order { crate::indic::to_visual(run) } else { run.to_owned() }; + ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?); } else { - ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + for run in (self.encode)(run).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if run.name != cursor.shown_font { + ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)])); + cursor.shown_font = run.name; + } + if let Some(tc) = run.spacing { + // A variation selector's empty glyph: shown at spacing zero, then the paragraph's + // own spacing back, so it adds no advance. + ops.push(Op::new("Tc", vec![n(tc)])); + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + ops.push(Op::new("Tc", vec![n(self.char_spacing)])); + } else { + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + } + } } } Ok(()) @@ -881,6 +915,23 @@ pub(crate) fn rewrite_block_from( let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style, b.phrase_size())?; let source = source_font(doc, &fonts_res, &font_name, first, &flat, style); let reuse = source.reuse; + // Preserve every drawable source glyph; cache missing-glyph lookups as well. + let visual_indic = source.metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; + let hebrew = flat.chars().any(crate::bidi::is_hebrew); + let source_advances: HashMap> = source + .metrics + .as_ref() + .filter(|_| !reuse && upright && !hebrew && !visual_indic && style.family.is_none() && style.bold.is_none() && needs_type3(&flat)) + .map(|m| { + let mut advances = HashMap::new(); + for ch in flat.chars() { + advances.entry(ch).or_insert_with(|| source_character_advance(m, ch, size, o_state.char_spacing)); + } + advances + }) + .unwrap_or_default(); + let source_chars: HashSet = source_advances.iter().filter_map(|(&ch, width)| width.map(|_| ch)).collect(); + let partial_reuse = !source_chars.is_empty(); // The chosen style also determines the real Japanese fallback outlines and advances. let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic)); let bold = style.bold.unwrap_or(bold); @@ -910,9 +961,15 @@ pub(crate) fn rewrite_block_from( let right_edge = if anchored_right { (b.rect[2] - b.rect[0]).max(0.0) } else { width }; let movement = drawn_movement(emit.ctm, dx, dy)?; let tm = emit_text_matrix(o, emit)?; - let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; + let type3 = if !reuse && needs_type3(&flat) { + let missing: String = flat.chars().filter(|ch| !source_chars.contains(ch)).collect(); + Some(type3_font(doc, &mut fonts_res, &missing, family, bold, max_glyphs)?) + } else { + None + }; let source_metrics = source.metrics.as_ref().filter(|_| reuse); let advance = Advance { + partial_advances: &source_advances, source: source_metrics, type3: type3.as_ref(), family, @@ -958,10 +1015,12 @@ pub(crate) fn rewrite_block_from( None => type3.is_none(), }; let writer = LineWriter { + source_name: &font_name, + partial_chars: partial_reuse.then_some(&source_chars), advance: &advance, alignment: Alignment { width, k, anchored_right, right_edge, rtl }, encode: &font.encode, - source: source_metrics, + source: source.metrics.as_ref().filter(|_| reuse || partial_reuse), align, aligns: &style.aligns, word_spacing_reaches_spaces, @@ -995,16 +1054,18 @@ pub(crate) fn rewrite_block_from( }; let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; let on_top = crate::lift_on_top(over, &mut block_ops); - let n = pdfcraft_content::num; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { block_ops.insert(0, Op::new("ET", vec![])); - block_ops.push(Op::new("BT", vec![])); - block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); + block_ops.extend(reopen_text_object(emit.tlm, emit.tm, after.font.as_ref().map(|(_, size)| *size), after.scale)); } + let mut advances = dropped_advances(doc, &ops, &res, &dropped); let new_data = splice(&pieces, &ops, |i| { - let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; - (insert, !dropped.contains(&i)) + let mut insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; + let keep = !dropped.contains(&i); + insert.extend(dropped_side_effects(&ops, i, keep, split.then_some(start))); + insert.extend(advances.remove(&i)); + (insert, keep) }); let contents = rewritten_contents(doc, &streams, new_data)?; let new_font = !reuse; diff --git a/crates/edit/src/text_regions/ocr_words.rs b/crates/edit/src/text_regions/ocr_words.rs index d511ccdf47..518fa4a3c1 100644 --- a/crates/edit/src/text_regions/ocr_words.rs +++ b/crates/edit/src/text_regions/ocr_words.rs @@ -150,8 +150,8 @@ fn key(line: &TextLine) -> (&str, [i64; 4]) { } /// How far right a rewrite of the runs `indices` drew (`None` when it drew nothing), or an error -/// when it moved or lost any other text on the page: text positioned after the end of the text -/// before it, say, depends on the words that went. +/// when it moved or lost any other text on the page: text positioned after the end of the words +/// that went, say, when their advance couldn't be left behind (see `dropped_advances`). fn drawn_right(before: &[TextLine], indices: &[usize], after: &[TextLine]) -> Result, EditError> { let mut unmatched: HashMap<_, usize> = HashMap::new(); for line in before { diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 487bb0e20e..6bb09b8891 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -116,8 +116,11 @@ struct LegacyFace { space: f64, } -/// What one edit may decode, and so keep, of the font programs of its THSarabunPSK subsets -/// (each at most 16 MiB): a page may name many of them. +/// What one edit may decode of its THSarabunPSK subsets' font data: their programs (each at most +/// 16 MiB), which it keeps, and the streams of the metrics read to measure them and to adapt their +/// programs' cmaps: their CMaps and glyph maps, and a subset's program again, for which of its +/// glyphs have outlines. A page may name many of them. The metrics read to find the subsets among +/// the page's fonts aren't charged to it. const MAX_THAI_PROGRAMS: usize = 64 << 20; struct PreparedCharacters { @@ -900,6 +903,9 @@ fn legacy_thai_font( } let family = pdfcraft_fonts::embedding::face_key(&source.base_font); let mut candidates = vec![source.font.clone()]; + // Every name on the page is read here for its face name alone, aliases of one font and other + // faces' fonts too, with no total, as before: charged to the edit's allowance, each alias would + // pay again and other faces would spend what the face's subsets need. candidates.extend(fonts.iter().filter_map(|(name, font)| { let font = doc.dict(font)?; (pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned()) @@ -914,9 +920,15 @@ fn legacy_thai_font( let face = match object.and_then(|o| cache.values().find(|face| face.object == Some(o))) { Some(alias) => LegacyFace { glyphs: alias.glyphs.clone(), metrics: alias.metrics.clone(), object, ..*alias }, None => { - let metrics = Metrics::from_dict(doc, &font); - let space = source_space(doc, fonts, &metrics); - let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new); + // Read within the edit's allowance; past it the subset goes unread, and the edit is + // refused below. + let metrics = Metrics::from_dict_within(doc, &font, budget); + let (space, glyphs) = if metrics.past_allowance { + (0.0, None) + } else { + let space = source_space(doc, fonts, &metrics); + (space, pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new)) + }; let style = FontStyle::of(doc, &font, &metrics); LegacyFace { glyphs, metrics, style, object, space } } @@ -924,9 +936,11 @@ fn legacy_thai_font( cache.insert(name, face); } // A subset left unread would count as unusable, silently changing which subset draws the text. - if budget.remaining() == 0 { + // A read refused for want of the allowance is what refuses the edit: reads that spent it to the + // byte refused nothing. + if budget.refused() > 0 { return Err(EditError::Invalid(format!( - "the page's {} subsets need more than {} MiB of font programs for one edit; choose a replacement font for this text", + "the page's {} subsets need more than {} MiB of font data for one edit; choose a replacement font for this text", source.base_font.rsplit('+').next().unwrap_or(&source.base_font), MAX_THAI_PROGRAMS >> 20 ))); @@ -1532,7 +1546,8 @@ fn push_row_break_marker(block_ops: &mut Vec, row_soft: &[bool], row_gap: &[ /// [`splice`] with the rewritten rows spliced in at `at`, and (when `marker` names one) a `/P` /// marker opened at its start and closed at its `next` line or, when nothing follows, after the /// paragraph's own last `ET` (appended past the stream's last operator when that close is at or -/// beyond `ops.len()`, since `splice`'s callback never visits that index). +/// beyond `ops.len()`, since `splice`'s callback never visits that index). A dropped operator +/// `i` leaves `left_behind(i, keep)` in its place: its line move, spacing and advance. fn splice_with_marker( pieces: &Pieces, ops: &[Op], @@ -1540,6 +1555,7 @@ fn splice_with_marker( at: usize, mut block_ops: Vec, drop: &std::collections::HashSet, + mut left_behind: impl FnMut(usize, bool) -> Vec, ) -> Vec> { let data = splice(pieces, ops, |i| { let mut insert = Vec::new(); @@ -1557,7 +1573,9 @@ fn splice_with_marker( if i == at { insert.append(&mut block_ops); } - (insert, !drop.contains(&i)) + let keep = !drop.contains(&i); + insert.extend(left_behind(i, keep)); + (insert, keep) }); if !marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) { return data; @@ -1882,14 +1900,21 @@ pub(crate) fn rewrite_styled_block( block_ops.extend(after.ops()); if split { block_ops.insert(0, Op::new("ET", vec![])); - block_ops.push(Op::new("BT", vec![])); - block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); + block_ops.extend(crate::text::reopen_text_object(o.tlm, o.tm, after.font.as_ref().map(|(_, size)| *size), after.scale)); } // A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is // safe. The close is not part of `block_ops`: that vector can end inside a text object // when the rewrite splits one, and a kept row's operators come later in the stream. let marker = if marked { None } else { paragraph_marker(block, lines, &ops) }; - let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop); + // A dropped `'` or `"` leaves its line move and spacing, and a dropped show its advance. + let mut advances = crate::text::dropped_advances(doc, &ops, &res, &drop); + let split_at = split.then_some(start); + let left_behind = |i: usize, keep: bool| { + let mut out = crate::text::dropped_side_effects(&ops, i, keep, split_at); + out.extend(advances.remove(&i)); + out + }; + let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop, left_behind); let contents = rewritten_contents(doc, &streams, data)?; res.set(b"Font".to_vec(), Object::Dict(fonts)); doc.update_dict(p.obj, |d| { diff --git a/crates/engine/README.md b/crates/engine/README.md index 86492bc043..3962afc50d 100644 --- a/crates/engine/README.md +++ b/crates/engine/README.md @@ -78,6 +78,7 @@ family (`sign`, `sign_with_timestamp`, `timestamp_document`, `embed_ltv`). | `catalog` | Acrobat's "All tools" information architecture as data — groups, sections, icons, hues, and an `Availability` of `Ready`, `Planned(milestone)` or `Provider`. The UI renders what is *not* built yet, honestly | | `actions` | PDF action dispatch (go-to, URI, named, set-layer-state, JavaScript) | | `links` | Link creation, properties, and finding URLs in page text | +| `open_parameters` | A path's PDF open parameters (`manual.pdf#nameddest=Intro`, `#page=12`): the file to open and where to show it. The app and `doc_open` both read them | | `js` | The bridge to `pdfcraft-js`. `forms` never depends on `js` directly; the engine injects it, so a build without JavaScript is possible | | `xfa`, `ocr`, `compare`, `export` | Façades over the L3/L4 crates of the same names | diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index eb213f3f64..2694e90492 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -420,13 +420,10 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi if let Some((added_idx, added_item)) = matched_added && let AddedContent::Image(ai) = added_item.content.clone() { + // Page visibility alone is not a crop: dragging an added image over the page edge + // must not discard its editable added-item identity on the next resize. let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6) - || img.visible_rect.is_some_and(|v| { - (v[0] - img.rect[0]).abs() > 1e-4 - || (v[1] - img.rect[1]).abs() > 1e-4 - || (v[2] - img.rect[2]).abs() > 1e-4 - || (v[3] - img.rect[3]).abs() > 1e-4 - }); + || img.frame_rect().is_none_or(|frame| frame.iter().zip(img.rect).any(|(a, b)| (a - b).abs() > 1e-4)); let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _]) if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5))); let should_promote = has_active_crop diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 31f1aed19e..d2b698a370 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -81,13 +81,13 @@ impl JsOutput { } /// Adds what a field's own script produced: its alerts, console lines, errors and requests, - /// except a request to show a side panel or open Save As, which is noted on the console - /// and dropped. + /// except a request to show a side panel, open Save As, or open or save an attached file, + /// which is noted on the console and dropped. fn absorb(&mut self, o: &Outcome) { self.alert(o.alerts.iter().cloned()); self.say(o.console.iter().cloned()); // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a - // button's may show or hide a side panel, or open Save As. + // button's may show or hide a side panel, open Save As, or open or save an attached file. // The warnings go through `say` like any other console line, so they count against the // line and byte caps instead of growing the console past them. let mut notes = Vec::new(); @@ -95,6 +95,9 @@ impl JsOutput { match r { Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()), Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()), + Request::OpenAttachment { name, .. } => { + notes.push(format!("A field script asked to open the attached file {name}; only a button may open an attached file")) + } r => self.requests.push(r.clone()), } } @@ -510,10 +513,12 @@ impl Session { } let doc = self.get(id).ok_or(EditError::NoDocument)?; let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - // A button PdfCraft generated from an XFA template runs its XFA click script. + // A button PdfCraft generated from an XFA template runs its XFA click script (the app runs + // click scripts for buttons only, so other fields' click events don't run here either). if let Some(t) = target && let Some(tpl) = doc.xfa_template.clone() && let Some(field) = doc.form.iter().find(|f| f.name == t) + && field.kind == crate::FormFieldKind::PushButton && let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field) { // The click returns only the output of its own edit; output from opening or from earlier edits stays. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 17f196cf6a..97bf07b7af 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -40,6 +40,7 @@ pub mod model3d; mod object_clip; pub mod ocr; mod open; +pub mod open_parameters; pub mod optimizer; mod page_filter; mod pictures; diff --git a/crates/engine/src/open_parameters.rs b/crates/engine/src/open_parameters.rs new file mode 100644 index 0000000000..83aed4f02d --- /dev/null +++ b/crates/engine/src/open_parameters.rs @@ -0,0 +1,101 @@ +//! PDF open parameters on a path (#3425): help systems and other programs start a PDF reader with +//! `manual.pdf#nameddest=Intro` or `manual.pdf#page=12` to show one place in a document. The +//! parameters follow Adobe's public "Parameters for Opening PDF Files": `name=value` pairs after a +//! `#`, separated by `&` or `#`. PdfCraft reads `page` (1-based) and `nameddest`, and, as browsers' +//! PDF viewers do, a fragment without `=` as a destination name. Other parameters (`zoom`, `view`, +//! `pagemode`, `search`, …) are ignored, and the file still opens. + +use pdfcraft_render::{DestView, RemoteDestination}; + +/// How many `#` of one path are tried as the end of its file name. +const MAX_CANDIDATES: usize = 32; + +/// The file `path` names and the destination its open parameters ask for, when `path` is not a +/// file itself but what comes before one of its `#` is. The first such `#` counts, so a folder or +/// file name may contain `#` too. `None`: open `path` as it is. +pub fn split(path: &str, is_file: impl Fn(&str) -> bool) -> Option<(String, RemoteDestination)> { + if is_file(path) { + return None; + } + let (file, fragment) = path.match_indices('#').take(MAX_CANDIDATES).find_map(|(i, _)| { + // `#` is one byte, so `i` and `i + 1` are char boundaries. + let file = path.get(..i)?; + is_file(file).then(|| (file, path.get(i + 1..).unwrap_or_default())) + })?; + Some((file.to_string(), destination(fragment))) +} + +/// The destination of a fragment's parameters; the last `page` or `nameddest` wins, as each +/// parameter is applied in turn. A page before the first, or one that isn't a number, is ignored. +fn destination(fragment: &str) -> RemoteDestination { + if !fragment.contains('=') { + return if fragment.is_empty() { RemoteDestination::Default } else { RemoteDestination::Named(fragment.to_string()) }; + } + let mut destination = RemoteDestination::Default; + for (key, value) in fragment.split(['&', '#']).filter_map(|p| p.split_once('=')) { + if key.eq_ignore_ascii_case("page") { + if let Some(page) = value.trim().parse::().ok().and_then(|n| n.checked_sub(1)) { + destination = RemoteDestination::Page { page, view: DestView::Top }; + } + } else if key.eq_ignore_ascii_case("nameddest") && !value.is_empty() { + destination = RemoteDestination::Named(value.to_string()); + } + } + destination +} + +#[cfg(test)] +mod tests { + use super::*; + + fn files(names: &'static [&'static str]) -> impl Fn(&str) -> bool { + move |p| names.contains(&p) + } + + fn page(page: usize) -> RemoteDestination { + RemoteDestination::Page { page, view: DestView::Top } + } + + fn named(name: &str) -> RemoteDestination { + RemoteDestination::Named(name.to_string()) + } + + #[test] + fn a_named_destination_or_page_after_the_file_name_is_split_off() { + let manual = files(&[r"C:\Help\manual.pdf"]); + // #3425: DIgSILENT PowerFactory's F1 help. + assert_eq!(split(r"C:\Help\manual.pdf#nameddest=Load Flow", &manual), Some((r"C:\Help\manual.pdf".into(), named("Load Flow")))); + assert_eq!(split(r"C:\Help\manual.pdf#page=12", &manual), Some((r"C:\Help\manual.pdf".into(), page(11)))); + // A bare fragment names a destination, as in a browser. + assert_eq!(split(r"C:\Help\manual.pdf#intro", &manual), Some((r"C:\Help\manual.pdf".into(), named("intro")))); + } + + #[test] + fn several_parameters_apply_in_turn_and_unknown_ones_are_ignored() { + let pdf = files(&["/docs/a.pdf"]); + assert_eq!(split("/docs/a.pdf#page=3&zoom=200,0,0", &pdf), Some(("/docs/a.pdf".into(), page(2)))); + assert_eq!(split("/docs/a.pdf#zoom=50#nameddest=x", &pdf), Some(("/docs/a.pdf".into(), named("x")))); + assert_eq!(split("/docs/a.pdf#nameddest=x&page=4", &pdf), Some(("/docs/a.pdf".into(), page(3)))); + assert_eq!(split("/docs/a.pdf#PAGE=2", &pdf), Some(("/docs/a.pdf".into(), page(1)))); + // The file opens at its default view when nothing can be applied. + for odd in + ["/docs/a.pdf#", "/docs/a.pdf#page=0", "/docs/a.pdf#page=two", "/docs/a.pdf#page=-1", "/docs/a.pdf#nameddest=", "/docs/a.pdf#view=Fit"] + { + assert_eq!(split(odd, &pdf), Some(("/docs/a.pdf".into(), RemoteDestination::Default)), "{odd}"); + } + assert_eq!(split("/docs/a.pdf#page=99999999999999999999999", &pdf), Some(("/docs/a.pdf".into(), RemoteDestination::Default))); + } + + #[test] + fn a_path_that_names_a_file_is_left_alone() { + // A file whose name has a `#` in it, alone and with parameters of its own. + let hashed = files(&["/docs/C#.pdf", "/docs/#1/a.pdf"]); + assert_eq!(split("/docs/C#.pdf", &hashed), None); + assert_eq!(split("/docs/C#.pdf#page=2", &hashed), Some(("/docs/C#.pdf".into(), page(1)))); + assert_eq!(split("/docs/#1/a.pdf#page=2", &hashed), Some(("/docs/#1/a.pdf".into(), page(1)))); + // Nothing before a `#` is a file: the open fails on the path as given. + assert_eq!(split("/docs/missing.pdf#page=2", &hashed), None); + assert_eq!(split("/docs/plain.pdf", &hashed), None); + assert_eq!(split("#", &hashed), None); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3912bfd5ec..c18117ba42 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8550,6 +8550,20 @@ fn an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error() { s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Check(true) }).expect("can be checked now"); } +/// A laid-out XFA form tabs the way its template's traversal says: the probe form, which +/// another viewer tabs `start A2 A3 A4 A1 B1 B3 B2 B4 C3 D1 D4 D6 finish C1 C2`. +#[test] +fn xfa_forms_tab_in_their_templates_traversal_order() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::traversal_template())); + let mut s = Session::new(); + let id = s.open("tab.pdf", None, bytes, None).unwrap(); + let doc = s.get(id).unwrap(); + let mut tabs: Vec<(usize, &str)> = doc.form.iter().flat_map(|f| f.widgets.iter().map(move |w| (w.tab, f.name.as_str()))).collect(); + tabs.sort_unstable(); + let names: Vec<&str> = tabs.iter().map(|t| t.1).filter(|n| !["log", "D5"].contains(n)).collect(); + assert_eq!(names, ["start", "A2", "A3", "A4", "A1", "B1", "B3", "B2", "B4", "C3", "D1", "D4", "D6", "finish", "C1", "C2"]); +} + /// A scripted form end to end: initialize and calculate on open, recalculation after a change, /// a failing validation's message, buttons that add and remove rows and show messages, a check /// box that shows a subform (undoable), FormCalc, and a save and reopen that keep it all. @@ -11338,6 +11352,61 @@ fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}"); } +/// `run_javascript` runs an XFA click script only for a push button, as the app does: a text +/// field with a click event of its own, named as the target, asks to open no attached file. +#[test] +fn run_javascript_runs_xfa_click_scripts_only_for_buttons() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("guide.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + s.take_js_output(id); + let o = s.run_javascript(id, "", Some("guide")).unwrap(); + assert_eq!(o.requests, [pdfcraft_js::Request::OpenAttachment { name: "Guide.pdf".into(), open: true }], "the button's click: {o:?}"); + let o = s.run_javascript(id, "", Some("note")).unwrap(); + assert!(o.requests.is_empty(), "a text field's click script doesn't run here: {o:?}"); +} + +/// In an XFA form a button's click script may open an attached file; the same request from a +/// text field's exit script is noted in the console and not made. +#[test] +fn an_xfa_button_may_open_an_attached_file_and_other_events_may_not() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("guide.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + s.take_js_output(id); + s.apply(id, Edit::XfaEvent { som: "form[0].guide[0]".into(), activity: "click".into() }).unwrap(); + let out = s.take_js_output(id); + assert_eq!(out.requests, [pdfcraft_js::Request::OpenAttachment { name: "Guide.pdf".into(), open: true }], "{out:?}"); + s.apply(id, Edit::SetFieldValue { name: "note".into(), value: FieldValue::Text("x".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "only a click opens a file: {out:?}"); + assert!(out.console.iter().any(|c| c.contains("open an attached file")), "{:?}", out.console); +} + /// A field whose layout:ready script only reads the page count keeps the value its script gives /// it: only a field the script gives the page call's result is numbered by the layout. #[test] @@ -13237,10 +13306,35 @@ fn redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again() { assert_eq!(globals(&doc, images[2]), None, "the DeviceN page is coded alone"); pdfcraft_redact::apply(&mut doc, None).unwrap(); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); - let named = saved.scan_objects().filter(|(_, o)| matches!(&**o, Object::Stream(s) if saved.jbig2_globals_ref(s).is_some())).count(); + // Optimize writes the parameters direct, so a stream that names the dictionary holds the key. + let names = |s: &Stream| { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut bytes); + bytes.windows(12).any(|w| w == b"JBIG2Globals") + }; + let named = saved.scan_objects().filter(|(_, o)| matches!(&**o, Object::Stream(s) if names(s))).count(); assert_eq!(named, 0, "no image names the dictionary, which left with them"); } +#[test] +fn resizing_an_added_image_back_inside_the_page_keeps_its_identity() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([-30.0, -20.0, 70.0, 80.0]), name: "edge.png".into(), bytes: Arc::new(png) }).unwrap(); + let original = s.get(id).unwrap().added()[0].obj; + let target = [10.0, 10.0, 60.0, 60.0]; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(target) }).unwrap(); + let added = s.get(id).unwrap().added(); + assert_eq!(added.len(), 1); + assert_eq!(added[0].obj, original); + assert_eq!(added[0].content.rect(), target); +} + #[test] fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() { let (mut session, id) = rotated_blank(0); diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 393b997af5..9b28320a55 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -53,6 +53,7 @@ fn click_only(e: &XfaEffect) -> Option<&'static str> { XfaEffect::Print => Some("print"), XfaEffect::SaveAs => Some("save"), XfaEffect::LaunchUrl(_) => Some("open a link"), + XfaEffect::OpenAttachment { .. } => Some("open an attached file"), XfaEffect::ShowPanel(_) => Some("show a side panel"), XfaEffect::SetFocus(_) => Some("move the focus"), _ => None, @@ -910,6 +911,7 @@ impl<'a, 'b> Runner<'a, 'b> { XfaEffect::Print => self.run.out.requests.push(Request::Print), XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs), XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())), + XfaEffect::OpenAttachment { name, open } => self.run.out.requests.push(Request::OpenAttachment { name: name.clone(), open: *open }), XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)), XfaEffect::SetFocus(som) => { if let Some(name) = self.by_som().get(som).cloned() { diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs index 5739c57160..be2ff1883e 100644 --- a/crates/engine/src/xfa_form.rs +++ b/crates/engine/src/xfa_form.rs @@ -15,13 +15,10 @@ pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result Result<(), String> { - for f in pdfcraft_forms::fields(doc) { - if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { - continue; - } - pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; - } - Ok(()) + let signed = |doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field| { + f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) + }; + pdfcraft_forms::redraw_fields(doc, |doc, f| !signed(doc, f)).map_err(|e| e.to_string()) } /// A warning for each field whose appearance PdfCraft drew with `?` for a character its font diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 078815e172..ee191add98 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -419,18 +419,21 @@ fn a_formatting_change_redraws_the_whole_ocr_phrase() { assert_eq!(drawn.join(" "), "John Smyth Example"); } -/// A layer whose words follow one another with no position of their own: a word that went would -/// move those after it, so keeping them isn't possible and the whole phrase is redrawn instead. +/// A layer whose words follow one another with no position of their own: a word that goes leaves +/// its advance behind (ISO 32000-2 §9.4.4), so the words after it stay where they were and are +/// kept as the scan, with nothing drawn. #[test] -fn words_that_depend_on_the_one_before_are_redrawn_rather_than_moved() { +fn words_that_depend_on_the_one_before_are_kept_in_place() { let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj ( ) Tj (Example) Tj ET"); let mut s = Session::new(); let id = s.open("relative.pdf", None, bytes, None).unwrap(); assert_eq!(texts(&s, id), ["John Smith Example"]); + let right = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Example")).map(|l| l.rect[2]); + let before = right(&s).unwrap(); edit_block(&mut s, id, 0, "John Example"); let (scan, drawn) = scan_and_drawn(&s, id); - assert!(scan.is_empty(), "no word is left to shift under the scan: {scan:?}"); - assert_eq!(drawn, ["John Example"]); + assert_eq!((scan, drawn), (vec!["John".to_owned(), "Example".to_owned()], Vec::::new())); + assert_eq!(right(&s), Some(before), "Example stays where it was"); } /// The edit survives a save and reopen, and each piece can be edited again. @@ -460,19 +463,21 @@ fn a_draft_with_the_same_words_changes_nothing() { } } -/// Visible text positioned after the end of an OCR word depends on that word. An edit may not -/// leave the word gone and the visible text somewhere else while calling the rest kept. +/// Visible text positioned after the end of an OCR word depends on that word. The word that goes +/// leaves its advance behind, so the visible text stays where it was and the word kept stays the +/// scan's. #[test] -fn visible_text_that_follows_an_ocr_word_stops_it_being_kept_alone() { +fn visible_text_that_follows_a_removed_ocr_word_stays_in_place() { let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj 0 Tr /F1 14 Tf ( Label) Tj ET"); let mut s = Session::new(); let id = s.open("label.pdf", None, bytes, None).unwrap(); let label = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Label")).map(|l| l.rect[0]); - assert_eq!(scan_and_drawn(&s, id).0, ["John Smith"]); + assert_eq!(scan_and_drawn(&s, id), (vec!["John Smith".to_owned()], vec!["Label".to_owned()])); let before = label(&s).unwrap(); edit_block(&mut s, id, 0, "John"); - let (scan, _) = scan_and_drawn(&s, id); - assert!(scan.is_empty(), "John can't stay alone when Label would move with it: {scan:?}, Label at {:?} from {before}", label(&s)); + // Nothing new is drawn: the visible text is the page's own. + assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned()], vec!["Label".to_owned()])); + assert_eq!(label(&s), Some(before), "Label stays where it was"); } /// A large word right before a small one, as OCR's sizes can make them: the large word's cover, diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs index 8c250ca484..e2b3379ac2 100644 --- a/crates/export/src/as_html.rs +++ b/crates/export/src/as_html.rs @@ -1,5 +1,5 @@ use super::as_docx::{hex, rgb8}; -use super::layout::{Item, Page, items}; +use super::layout::{Item, Page, Run, Table, items}; /// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, /// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them @@ -94,6 +94,64 @@ pub fn html_simple_with_language(pages: &[Page], title: &str, language: Option<& html_with_mode(pages, title, true, language) } +fn style_color(color: [f64; 3], simple: bool) -> String { + if simple { String::new() } else { hex(color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) } +} + +fn wrap_inline(mut text: String, bold: bool, italic: bool) -> String { + if text.trim().is_empty() { + return text; + } + if italic { + text = format!("{text}"); + } + if bold { + text = format!("{text}"); + } + text +} + +fn para_html(runs: &[Run<'_>], lvl: u8, simple: bool) -> Option { + let non_empty: Vec<_> = runs.iter().map(|r| (r.block, esc(&r.text))).filter(|(_, text)| !text.is_empty()).collect(); + let &(first, _) = non_empty.iter().find(|(_, text)| !text.trim().is_empty())?; + let mut t = String::new(); + for (block, text) in non_empty { + if text.trim().is_empty() { + t.push_str(&text); + continue; + } + let mut run = wrap_inline(text, block.bold && lvl == 0, block.italic); + // The paragraph has its first visible run's colour; a run in another colour says so. + let colour = rgb8(block.color); + if !simple && colour != rgb8(first.color) { + let [red, green, blue] = colour; + run = format!("{run}"); + } + t.push_str(&run); + } + let style = style_color(first.color, simple); + Some(match lvl.min(6) { + 0 => format!("{t}

\n"), + l => format!("{t}\n"), + }) +} + +fn table_html(t: &Table, simple: bool) -> String { + let mut s = String::from("\n"); + for row in &t.rows { + s.push_str(""); + for c in row { + let body = wrap_inline(esc(&c.text), c.bold, c.italic); + let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; + let style = style_color(c.color, simple); + s.push_str(&format!("{body}")); + } + s.push_str("\n"); + } + s.push_str("
\n"); + s +} + fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&str>) -> String { let lang = language_tag(language).map(|tag| format!(" lang=\"{}\"", esc(tag))).unwrap_or_default(); let mut s = format!( @@ -108,50 +166,11 @@ fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&s for it in items(pages) { match it { Item::Para(runs, lvl) => { - let Some(first) = runs.first().map(|r| r.block) else { continue }; - let mut t = String::new(); - for r in &runs { - let mut run = esc(&r.text); - if r.block.italic { - run = format!("{run}"); - } - if r.block.bold && lvl == 0 { - run = format!("{run}"); - } - // The paragraph has its first run's colour; a run in another colour says so. - let colour = rgb8(r.block.color); - if !simple && colour != rgb8(first.color) { - let [red, green, blue] = colour; - run = format!("{run}"); - } - t.push_str(&run); - } - let style = if simple { String::new() } else { hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; - match lvl { - 0 => s.push_str(&format!("{t}

\n")), - l => s.push_str(&format!("{t}\n")), + if let Some(p) = para_html(&runs, lvl, simple) { + s.push_str(&p); } } - Item::Table(t) => { - s.push_str("\n"); - for row in &t.rows { - s.push_str(""); - for c in row { - let mut body = esc(&c.text); - if c.italic { - body = format!("{body}"); - } - if c.bold { - body = format!("{body}"); - } - let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; - let style = if simple { String::new() } else { hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; - s.push_str(&format!("{body}")); - } - s.push_str("\n"); - } - s.push_str("
\n"); - } + Item::Table(t) => s.push_str(&table_html(&t, simple)), Item::Img(im) => { let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); @@ -162,3 +181,71 @@ fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&s s.push_str("\n\n"); s } + +#[cfg(test)] +mod tests { + use super::{html, para_html, table_html}; + use crate::layout::{Block, Cell, Page, Run, Table}; + + fn block(text: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block { + Block { + text: text.into(), + rect: [x0, y0, x1, y1], + size: 12.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + } + } + + #[test] + fn empty_or_control_only_runs_and_blocks_do_not_emit_empty_tags() { + let page = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + Block { bold: true, italic: true, color: [1.0, 0.0, 0.0], ..block("\u{1}\u{FFFF}", 72.0, 700.0, 90.0, 712.0) }, + Block { color: [0.0, 0.5, 0.0], ..block("Hello", 90.5, 700.0, 140.0, 712.0) }, + Block { bold: true, ..block("\u{0}\u{2}", 72.0, 660.0, 120.0, 672.0) }, + ], + images: Vec::new(), + background: None, + rules: Vec::new(), + }; + let out = html(&[page], "Clean"); + assert!(out.contains("

Hello

"), "{out}"); + assert!(!out.contains(""), "{out}"); + assert!(!out.contains(""), "{out}"); + assert_eq!(out.matches(" Visible

\n") + ); + } + + #[test] + fn heading_level_is_clamped_and_empty_styled_cells_stay_clean() { + let b = block("Deep Heading", 72.0, 700.0, 200.0, 712.0); + let runs = [Run { text: "Deep Heading".into(), block: &b }]; + assert_eq!(para_html(&runs, 9, true).as_deref(), Some("
Deep Heading
\n")); + + let table = Table { + rect: [0.0, 0.0, 100.0, 20.0], + cols: vec![0.0, 50.0], + rows: vec![vec![ + Cell { text: "\u{1}".into(), size: 11.0, bold: true, italic: true, color: [0.0; 3], span: 1 }, + Cell { text: "Val".into(), size: 11.0, bold: true, italic: false, color: [0.0; 3], span: 1 }, + ]], + ruled: None, + }; + assert_eq!(table_html(&table, true), "\n\n
Val
\n"); + } +} diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 9342213577..4bee6b451c 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -212,6 +212,18 @@ pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_outp run(chain, data, max_output, Some(max_output), false, false, None) } +/// [`decode_tolerant_counted`] with the two bounds apart: `max_stage` bounds every stage, as +/// [`decode_tolerant`]'s bound does, and `max_output` the bytes all stages decode together. A +/// stage stopped by either gives [`FilterError::LimitExceeded`] and the bytes decoded before it. +pub fn decode_tolerant_counted_each( + chain: &[(Filter, Params)], + data: &[u8], + max_stage: usize, + max_output: usize, +) -> Result<(Vec, bool, usize), (FilterError, usize)> { + run(chain, data, max_stage, Some(max_output), false, false, None) +} + /// [`decode_tolerant`], spending from `work`, what decoding may still cost across a run of images: /// a step for each byte of `data`, paid before anything is decoded, [`FLATE_READ_WORK`] for each /// byte a Flate stage reads, paid before it runs (again if it reads them twice), and one for each @@ -451,6 +463,30 @@ mod tests { assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data)); } + /// [`decode_tolerant_counted_each`] holds every stage to its own bound as well as the stages + /// to their total: a Flate stage that would make more than that bound stops there, though + /// the total has room, and the stage after it gets nothing; within both, the chain reads + /// whole, one stage's output charged on top of another's. + #[test] + fn each_stage_is_held_to_its_own_bound_within_the_total() { + let hex = "00".repeat(1 << 16).into_bytes(); + let data = super::flate::encode(&hex); + let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())]; + // The total alone lets the Flate stage make all of the hex text, and the hex stage read it. + STAGE_BYTES.with(|c| c.set(0)); + let whole = decode_tolerant_counted(&chain, &data, 3 << 16).map(|(v, _, _)| v); + assert_eq!(whole.ok(), Some(vec![0u8; 1 << 16])); + assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len() + hex.len()); + // Each stage held to 64 KiB, the Flate stage stops there and the hex stage gets nothing. + STAGE_BYTES.with(|c| c.set(0)); + let held = decode_tolerant_counted_each(&chain, &data, 1 << 16, 3 << 16); + assert!(matches!(held, Err((FilterError::LimitExceeded(m), 0)) if m == 1 << 16), "{held:?}"); + assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len()); + // Within both, it reads whole. + let (out, _, spent) = decode_tolerant_counted_each(&chain, &data, 2 << 16, 3 << 16).unwrap(); + assert_eq!((out.len(), spent), (1 << 16, hex.len() + (1 << 16))); + } + use super::*; #[test] diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 9d8d0d6b85..f68761e02e 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -14,7 +14,7 @@ use std::borrow::Cow; use std::collections::{BinaryHeap, HashMap, HashSet}; -use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, Stream}; /// How the bytes of a string map to codes. #[derive(Clone, Debug, PartialEq)] @@ -67,6 +67,11 @@ pub struct Metrics { /// Subset CIDFontType2 only: one bit per CID (below 65 536) whose glyph has an outline in the /// embedded program. See [`cids_with_outlines`]. outlined_cids: Vec, + /// A stream these metrics come from (a CMap, the program, `/CIDToGIDMap`, a Type 3 glyph + /// description) was left unread because the decode allowance [`Metrics::from_dict_within`] + /// was given was spent: what it would have decided (code lengths, CIDs, widths, meanings, a + /// Type 3 font's height) is a guess. + pub past_allowance: bool, } fn nums(doc: &Document, o: Option<&Object>) -> Vec { @@ -79,6 +84,12 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option { o.and_then(|o| doc.resolve(o).as_dict().cloned()) } +/// Whether `font`'s `/Encoding` is the name Identity-H, given directly or through a reference, +/// as [`Metrics::from_dict`] reads it (a Type 0 font's `/Encoding` may be indirect). +fn identity_h(doc: &Document, font: &Dict) -> bool { + font.get(b"Encoding").is_some_and(|e| doc.resolve(e).as_name() == Some(b"Identity-H".as_slice())) +} + /// Whether `base_font` (subset tag allowed) names a legacy Indic font family that is laid out in /// visual order (Kruti Dev, DevLys, Chanakya, Shusha, Shivaji, Walkman, Akruti, APS-DV, DV-TT…). fn legacy_indic_font(base_font: &str) -> bool { @@ -92,14 +103,17 @@ fn legacy_indic_font(base_font: &str) -> bool { /// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export /// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer. pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { - embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0)) + // No total: one font's two programs, and the metrics read that may adapt one, are each + // bounded by that stream's own limit. + embedded_sfnt_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0)) } /// The largest embedded sfnt program read for the editor. const MAX_SFNT: usize = 16 << 20; /// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares: -/// each program decoded is charged whether or not it turns out to be usable. +/// each program decoded is charged whether or not it turns out to be usable, and so are the +/// streams of the metrics read that adapts a program without a Unicode cmap. pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option> { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()); @@ -108,7 +122,10 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(mut bytes) = budget.decode_within(&doc.with_direct_parms(stream), MAX_SFNT) else { continue }; + // As in `Reads::decode`: each stage within the limit, the program, the last, too, read + // through a view of the stream; once the allowance is spent a program with data is refused + // before its parameters are resolved. + let Ok(mut bytes) = budget.decode_stages_within(doc, stream, MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 { continue; @@ -120,13 +137,32 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg if own_cmap && !base_font.rsplit('+').next().is_some_and(|f| f.starts_with("THSarabunPSK")) { return Some(bytes); } + // A composite font's cmap is rebuilt only through Identity-H codes (`composite_glyph_maps`): + // with any other `/Encoding` nothing is, so its metrics aren't read, and a program with a + // cmap of its own is kept as it is, as when the rebuild finds nothing. + if font.name(b"Subtype") == Some(b"Type0") && !identity_h(doc, font) { + if own_cmap { + return Some(bytes); + } + continue; + } // PDF subsets may remove the Unicode cmap entirely. Rebuild an editor-only // cmap from ToUnicode and the actual CID-to-glyph map; source bytes stay unchanged. - let metrics = Metrics::from_dict(doc, font); + // Metrics read past the allowance, its widths' read of the program again included, + // adapt no copy. + let metrics = Metrics::from_dict_within(doc, font, budget); + if metrics.past_allowance { + continue; + } let mut mappings = std::collections::BTreeMap::new(); if metrics.composite { + let refused = budget.refused(); if let Some((map, _)) = composite_glyph_maps(doc, font, &bytes, &metrics, budget) { mappings = map; + } else if budget.refused() > refused { + // The map the cmap is rebuilt from was left unread for want of the allowance: no + // copy is listed, adapted or as it is, as for metrics read past it. + continue; } if own_cmap { let charmap = face.charmap(); @@ -166,7 +202,7 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg /// The CIDs (below 65 536) of a Type 0 font over an embedded TrueType program whose glyph has /// outline data, through `/CIDToGIDMap`; empty when the program or the map can't be read. /// Subsetters keep glyph ids and empty the glyphs they drop, so an empty glyph is a missing one. -fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { +fn cids_with_outlines(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec { use skrifa::raw::TableProvider as _; let Some(descendant) = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()) else { @@ -175,13 +211,13 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { let Some(program) = dict(doc, descendant.get(b"FontDescriptor")).and_then(|d| d.get(b"FontFile2").cloned()) else { return Vec::new() }; let program = doc.resolve(&program); let Object::Stream(stream) = program.as_ref() else { return Vec::new() }; - let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { return Vec::new() }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() }; let Some(loca) = skrifa::FontRef::from_index(&bytes, 0).ok().and_then(|face| face.loca(None).ok()) else { return Vec::new() }; let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(start, end)| end > start); match descendant.get(b"CIDToGIDMap").map(|o| doc.resolve(o)).as_deref() { None | Some(Object::Name(_)) => bit_set_below_64k((0..loca.len().min(0x1_0000) as u32).filter(|&gid| outlined(gid))), Some(Object::Stream(map)) => { - let Ok(map) = doc.with_direct_parms(map).decoded_within(1 << 17) else { return Vec::new() }; + let Some(map) = reads.decode(map, MAX_CID_TO_GID) else { return Vec::new() }; bit_set_below_64k( map.as_chunks::<2>() .0 @@ -200,7 +236,7 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { /// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up /// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself /// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess. -fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { +fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics, reads: &mut Reads<'_>) { use skrifa::instance::{LocationRef, Size}; use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _}; let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return }; @@ -208,7 +244,7 @@ fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { continue }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em); if upem == 0.0 { @@ -354,8 +390,48 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20; /// thousand. const MAX_CMAP_RANGES: usize = 1 << 16; /// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger -/// one is not decoded at all. +/// one is left unread (behind one filter not decoded past it; behind several, see `Reads::decode`). const MAX_CMAP_BYTES: usize = 16 << 20; +/// Largest decoded `/CIDToGIDMap` stream read: two bytes for each CID below 65,536. +const MAX_CID_TO_GID: usize = 1 << 17; + +/// The streams one reading of a font decodes, against the caller's allowance, and whether one was +/// left unread because that allowance was spent. +struct Reads<'a> { + doc: &'a Document, + budget: &'a mut DecodeBudget, + refused: bool, +} + +impl<'a> Reads<'a> { + fn new(doc: &'a Document, budget: &'a mut DecodeBudget) -> Self { + Self { doc, budget, refused: false } + } + + /// `stream` decoded within `max` and the allowance, its `/DecodeParms` read where they are given + /// by reference too, through a view of the stream that copies no more of its dictionary than + /// its filters and parameters; `None` when it can't be, noting when the allowance is why. + /// + /// Each of its filter stages is held to `max`, as a decode without an allowance holds it, and + /// the stages together to `max` for each of them ([`DecodeBudget::decode_stages_within`]): a + /// map of 65,536 CIDs behind ASCIIHex and Flate, its Flate data smaller than the map, makes + /// all of `MAX_CID_TO_GID` in its last stage, with the hex stage's output on top. So a stream + /// reads here when it reads without an allowance and what is left pays for it. A stream with a + /// stage past `max` is unread. When what is left is more than what the stages before that one + /// made and `max`, it is charged those two and isn't refused; otherwise, with more left than + /// the stages' total (behind one filter, `max`), it is charged that total and isn't refused; + /// with no more left than that total, it spends all of it and counts as refused. + fn decode(&mut self, stream: &Stream, max: usize) -> Option> { + match self.budget.decode_stages_within(self.doc, stream, max) { + Ok(data) => Some(data), + Err(_) => { + self.refused |= self.budget.remaining() == 0; + None + } + } + } +} + /// Most Type 3 glyph descriptions read for their `d1` box. A simple font shows at most 256 codes. const MAX_TYPE3_GLYPHS: usize = 256; /// Largest decoded Type 3 glyph description read for its `d1` box; a larger one is skipped. @@ -372,8 +448,8 @@ const MAX_TYPE3_EXTENT: f64 = 1e6; /// no glyph declares one (`d0` glyphs don't). The descriptions the encoding's `/Differences` /// names are chosen, since it "shall specify the complete character encoding" (Table 110); only a /// font without one has its first descriptions chosen. Reads at most [`MAX_TYPE3_GLYPHS`] -/// descriptions within [`MAX_TYPE3_FONT_BYTES`]. -fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> { +/// descriptions within [`MAX_TYPE3_FONT_BYTES`], each charged to the reading's allowance too. +fn type3_glyph_box(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Option<([f64; 4], bool)> { let procs = dict(doc, font.get(b"CharProcs"))?; let mut names: Vec<&[u8]> = Vec::new(); // Whether every glyph that can be drawn is read; if not, the union may be short of them. @@ -401,12 +477,30 @@ fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> { } else { names.iter().filter_map(|n| procs.get(n)).collect() }; - let mut budget = DecodeBudget::new(MAX_TYPE3_FONT_BYTES, 0); + // What is left of the font's own total: the descriptions decode against the reading's + // allowance, which charges them, and each within what is left of this. + let mut left = MAX_TYPE3_FONT_BYTES; let mut union: Option<[f64; 4]> = None; for proc in chosen { let Object::Stream(s) = &*doc.resolve(proc) else { continue }; - let Ok(data) = budget.decode_within(&doc.with_direct_parms(s), MAX_TYPE3_GLYPH_BYTES) else { - if budget.remaining() == 0 { + // Once the reading's allowance is spent the rest go unread, before their parameters are + // resolved. + if reads.budget.refuses(s) { + reads.refused = true; + complete = false; + break; + } + let before = reads.budget.remaining(); + let read = reads.budget.decode_with_damage(doc, s, MAX_TYPE3_GLYPH_BYTES.min(left)).map(|(data, _)| data); + left = left.saturating_sub(before.saturating_sub(reads.budget.remaining())); + let Ok(data) = read else { + if reads.budget.remaining() == 0 { + // The rest go unread too, for want of the reading's allowance. + reads.refused = true; + complete = false; + break; + } + if left == 0 { // The rest go unread too. complete = false; break; @@ -785,11 +879,25 @@ impl Metrics { code_len: 1, explicit_reverse: (32..127u8).map(|c| (char::from(c), u32::from(c))).collect(), outlined_cids: Vec::new(), + past_allowance: false, } } + /// The metrics of `font`. Each stream they come from decodes within its own limit (16 MiB for + /// a CMap or a program, 4 MiB for a Type 3 font's glyph descriptions together), and again on + /// every call: a caller that reads many fonts, or one font many times, shares an allowance + /// through [`Metrics::from_dict_within`]. pub fn from_dict(doc: &Document, font: &Dict) -> Self { - let mut m = Self::read_metrics(doc, font); + // No total, so nothing is refused: each stream stays within its own limit, as it always has. + Self::from_dict_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0)) + } + + /// [`Metrics::from_dict`], decoding against `budget`, which the caller shares across the fonts + /// it reads (a page's, say): each stream pays for what it decodes, usable or not. A stream the + /// allowance can't pay for is left unread, and [`Metrics::past_allowance`] says so. + pub fn from_dict_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Self { + let mut reads = Reads::new(doc, budget); + let mut m = Self::read_metrics(doc, font, &mut reads); let base = font .name(b"BaseFont") .map(|name| String::from_utf8_lossy(name).into_owned()) @@ -806,17 +914,18 @@ impl Metrics { m.italic |= italic; m.base_font = base; if m.composite && m.subset { - m.outlined_cids = cids_with_outlines(doc, font); + m.outlined_cids = cids_with_outlines(doc, font, &mut reads); } m.code_len = match &m.codes { Codes::One => 1, Codes::Two => 2, Codes::Ranges(r) => r.last().map_or(2, |x| x.0), }; - m.unicode = unicode_map(doc, font, m.composite); + m.unicode = unicode_map(doc, font, m.composite, &mut reads); if m.std14.is_some() { - embedded_widths(doc, font, &mut m); + embedded_widths(doc, font, &mut m, &mut reads); } + m.past_allowance = reads.refused; let mut reverse = HashMap::new(); for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) { if let Some(ch) = only_char(text) @@ -837,7 +946,7 @@ impl Metrics { m } - fn read_metrics(doc: &Document, font: &Dict) -> Self { + fn read_metrics(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Self { let mut m = Metrics::fallback(); m.std14 = None; let subtype = font.name(b"Subtype").unwrap_or(b"Type1").to_vec(); @@ -850,7 +959,7 @@ impl Metrics { m.codes = Codes::Two; if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e)) && let Object::Stream(s) = &*e - && let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) + && let Some(data) = reads.decode(s, MAX_CMAP_BYTES) { let (spaces, cids) = parse_cmap(&data); if !spaces.is_empty() { @@ -915,7 +1024,7 @@ impl Metrics { // A union cut short by a limit may miss glyphs that are drawn, so a nonzero // `/FontBBox` comes first then; all zero, the partial union is still better // than no box. A box with no height in text space gives way to the next. - let glyphs = type3_glyph_box(doc, font); + let glyphs = type3_glyph_box(doc, font, reads); // All zero: no assumption about glyph sizes (Table 110). let bbox = <[f64; 4]>::try_from(nums(doc, font.get(b"FontBBox"))).ok().filter(|r| r.iter().any(|&v| v != 0.0)); let union = |complete: bool| glyphs.filter(|g| g.1 == complete).map(|(r, _)| (r, 0.0, 0.0)); @@ -1088,8 +1197,14 @@ impl Metrics { } /// The bytes that show `text` in this font, or `None` if some character has no code or - /// no glyph in it (the caller then substitutes another font). + /// no glyph in it, or the metrics were read past their allowance (the caller then substitutes + /// another font). Empty text needs no verified glyph and always encodes. pub fn encode(&self, text: &str) -> Option> { + // A refused read cannot establish the mappings or glyphs needed to write new text. + // Empty text needs neither and remains usable for erasing existing text. + if self.past_allowance && !text.is_empty() { + return None; + } // The lowest code showing each wanted character. Explicit entries and the encoding are // checked one by one; each range is asked for the wanted characters it can show. let wanted: HashSet = text.chars().collect(); @@ -1432,7 +1547,7 @@ pub fn glyph_unicode(name: &str) -> Option { /// to [`symbolic_mac_roman_texts`]). A code whose glyph has no name with a Unicode value reads /// as WinAnsi, as the renderer reads it (vendored hayro-interpret patch (79)), so the editor and /// extraction agree: `€` for code 0x80, which Standard leaves without a character. -fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { +fn symbolic_glyph_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<(u32, String)> { use skrifa::GlyphId; use skrifa::raw::{FontData, FontRead as _, TableProvider as _, tables::cmap::PlatformId, tables::post::Post, types::Tag}; let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() }; @@ -1443,7 +1558,7 @@ fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { return Vec::new() }; - let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() }; let Ok(cmap) = face.cmap() else { return Vec::new() }; // A `post` version written with its 16-bit halves swapped (`00 00 00 02` for 2.0, as ScanSoft @@ -1534,7 +1649,7 @@ fn post_name<'a>(post: &skrifa::raw::tables::post::Post<'a>, custom: &[&'a str], /// Code → Unicode for a font: its ToUnicode CMap (for a composite font, also the name /// `/Identity-H` or `/Identity-V`, read as code = Unicode), else (simple fonts) its encoding. -fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { +fn unicode_map(doc: &Document, font: &Dict, composite: bool, reads: &mut Reads<'_>) -> Unicode { let mut out = Unicode::default(); if !composite { let enc = font.get(b"Encoding").map(|e| doc.resolve(e)); @@ -1579,8 +1694,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { // A font whose `post` table names its glyphs reads by those names; one that names none // and keeps the Macintosh order reads as Mac OS Roman, over the WinAnsi fallback. if base_name.is_none() && !symbolic_builtin { - out.encoding.extend(symbolic_glyph_texts(doc, font)); - out.encoding.extend(symbolic_mac_roman_texts(doc, font)); + out.encoding.extend(symbolic_glyph_texts(doc, font, reads)); + out.encoding.extend(symbolic_mac_roman_texts(doc, font, reads)); } if let Some(Object::Dict(d)) = enc.as_deref() && let Some(diffs) = d.get(b"Differences").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()) @@ -1606,10 +1721,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { } match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() { Some(Object::Stream(s)) => { - // With `/DecodeParms` given by reference, as every stream of the font is read here, - // and as the redaction proof reads the map: the two must get the same text, or a copy - // redaction rightly left counts as a survivor. - if let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) { + // Reads resolves indirect parameters too, matching the redaction proof's text. + if let Some(data) = reads.decode(s, MAX_CMAP_BYTES) { parse_to_unicode(&data, &mut out); } } @@ -1657,7 +1770,7 @@ fn identity_to_unicode() -> Vec { /// renderer finds it: the Windows (3, 0) and (3, 1) subtables first, with the code and the code at /// 0xF000, 0xF100 and 0xF200, then the Macintosh and Unicode-platform ones. The renderer's text /// layer reads the same codes the same way (vendored hayro-interpret patch (101)). -fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { +fn symbolic_mac_roman_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<(u32, String)> { use skrifa::GlyphId; use skrifa::raw::{TableProvider as _, tables::cmap::PlatformId, tables::post::DEFAULT_GLYPH_NAMES, types::Tag}; let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() }; @@ -1668,7 +1781,7 @@ fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { return Vec::new() }; - let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() }; // A CFF program in `/FontFile2` selects its glyphs by name, not through the `cmap`. A `post` // table of version 1.0 or 2.0 (also with its 16-bit halves swapped, as the renderer reads it) diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index 2d95465944..975b71289e 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -178,9 +178,10 @@ fn a_font_without_widths_measures_with_its_embedded_program() { } } -#[test] -fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { - use skrifa::{MetadataProvider as _, raw::TableProvider as _}; +/// Inter with its cmap replaced by a Mac format-6 one mapping code 65 to Ω's glyph, which egui +/// can't use; and that glyph. +fn mac_subset() -> (Vec, skrifa::GlyphId) { + use skrifa::MetadataProvider as _; let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); let face = skrifa::FontRef::new(original).unwrap(); let expected = face.charmap().map('Ω').unwrap(); @@ -194,6 +195,13 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); subset.extend_from_slice(&cmap); + (subset, expected) +} + +#[test] +fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; + let (subset, expected) = mac_subset(); let face = skrifa::FontRef::new(&subset).unwrap(); assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6"); assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected)); @@ -214,6 +222,331 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); } +/// The metrics read that adapts a program's cmap is paid for from the editor's allowance too: a +/// step short of the program, its ToUnicode and the program again for its widths and for the +/// readings of its codes by glyph name and as Mac OS Roman, the metrics are read past the allowance +/// and no copy is adapted. +#[test] +fn adapting_a_programs_cmap_is_paid_for() { + use skrifa::MetadataProvider as _; + let (subset, expected) = mac_subset(); + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let cmap = b"1 beginbfchar <41> <03A9> endbfchar"; + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap))); + let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]); + let cost = 4 * subset.len() + cmap.len(); + let mut budget = DecodeBudget::new(cost, 0); + let editor = embedded_sfnt_within(&doc, &source, &mut budget).unwrap(); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); + assert_eq!(budget.remaining(), 0); + assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(cost - 1, 0)).is_none()); + // A composite font whose codes aren't Identity-H can't be adapted, so its metrics aren't + // read: the program is all it pays for. + let mut budget = DecodeBudget::new(cost, 0); + let mut composite = source.clone(); + composite.set(b"Subtype".to_vec(), Object::name("Type0")); + assert!(embedded_sfnt_within(&doc, &composite, &mut budget).is_none()); + assert_eq!(budget.remaining(), cost - subset.len()); +} + +/// A Type 0 subset font whose `/Encoding`, program, `/CIDToGIDMap` and ToUnicode are streams, and +/// what each decodes to. +fn subset_type0(doc: &mut Document, to_unicode: &[u8]) -> (Dict, [usize; 4]) { + let encoding = b"1 begincodespacerange <0000> endcodespacerange"; + let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let map = [0u8, 0, 0, 1, 0, 2]; + let mut stream = |data: &[u8]| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), data)))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), stream(program)); + let mut descendant = Dict::new(); + descendant.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + descendant.set(b"CIDToGIDMap".to_vec(), stream(&map)); + let entries = vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("ABCDEF+Inter")), + ("Encoding", stream(encoding)), + ("DescendantFonts", Object::Array(vec![Object::Dict(descendant)])), + ("ToUnicode", stream(to_unicode)), + ]; + (font(doc, entries), [encoding.len(), program.len(), map.len(), to_unicode.len()]) +} + +/// Each stream a font's metrics come from is paid for from the allowance they are read within, +/// down to the byte; a stream it can't pay for is left unread, and the metrics say so. +#[test] +fn a_fonts_streams_are_paid_for_from_its_allowance() { + let mut doc = Document::new_empty(); + let (f, sizes) = subset_type0(&mut doc, b"1 beginbfchar <0001> <0058> endbfchar"); + let cost: usize = sizes.iter().sum(); + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance); + assert_eq!((whole.code_len, whole.text_of(1).as_deref()), (2, Some("X"))); + assert!(!whole.outlined_cids.is_empty(), "the program and its map were read"); + let mut budget = DecodeBudget::new(cost, 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole); + assert_eq!(budget.remaining(), 0); + // A byte short, the ToUnicode, read last, is left unread; what came before is kept. + let mut budget = DecodeBudget::new(cost - 1, 0); + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert!(m.past_allowance); + assert_eq!((m.code_len, m.text_of(1)), (2, None)); + assert_eq!(m.outlined_cids, whole.outlined_cids); + // With nothing left, none of them is read. + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert!(m.past_allowance && m.outlined_cids.is_empty()); + assert_eq!(m.codes, Codes::Two, "the Encoding CMap was left unread"); + // Only a subset's program says which glyphs it lacks: the whole font pays for its CMaps alone. + let mut whole_font = f.clone(); + whole_font.set(b"BaseFont".to_vec(), Object::name("Inter")); + let mut budget = DecodeBudget::new(cost, 0); + let m = Metrics::from_dict_within(&doc, &whole_font, &mut budget); + assert!(!m.past_allowance && m.outlined_cids.is_empty()); + assert_eq!(cost - budget.remaining(), sizes[0] + sizes[3]); +} + +/// With nothing left, an empty stream still reads, as the allowance reads one, and is no reason to +/// call the font read past it; a stream with data is left unread, and is. +#[test] +fn an_empty_stream_reads_with_nothing_left() { + let past = |data: &[u8]| { + let mut doc = Document::new_empty(); + let unicode = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec())))); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("ToUnicode", unicode)]); + Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(0, 0)).past_allowance + }; + assert!(!past(b"")); + assert!(past(b"1 beginbfchar <41> <0058> endbfchar")); +} + +/// A stream behind one filter past its own limit, with more left than its limit, is left unread as +/// it always was, paying for the limit, and is no reason to call the font read past the allowance. +#[test] +fn a_cmap_past_its_own_limit_is_not_past_the_allowance() { + let mut doc = Document::new_empty(); + let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec(); + cmap.resize(MAX_CMAP_BYTES + 1, b' '); + let (f, sizes) = subset_type0(&mut doc, &cmap); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert!(!m.past_allowance); + assert_eq!(m.text_of(1), None); + assert_eq!(usize::MAX / 2 - budget.remaining(), sizes[..3].iter().sum::() + MAX_CMAP_BYTES); +} + +/// A `/CIDToGIDMap` as long as a map can be, which compresses well, reads the same behind no +/// filter, Flate, or ASCIIHex and Flate: each stage may make as much as the map's limit, so the hex +/// stage's output on top of it doesn't leave it unread. A map past the limit is unread behind each. +#[test] +fn a_full_cid_map_reads_behind_one_or_two_filters() { + let read = |entries: usize, filters: usize| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..entries).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect(); + let flate = Stream::flate(Dict::new(), &map); + let stream = match filters { + 0 => Stream::from_raw(Dict::new(), map), + 1 => flate, + _ => { + let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, hex.into_bytes()) + } + }; + let map = Object::Ref(doc.add(Object::Stream(stream))); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + Metrics::from_dict(&doc, &f).outlined_cids + }; + let full = read(1 << 16, 1); + assert!(!full.is_empty()); + assert_eq!(read(1 << 16, 0), full); + assert_eq!(read(1 << 16, 2), full); + for filters in 0..=2 { + assert!(read((1 << 16) + 1, filters).is_empty(), "{filters} filters"); + } +} + +/// The editor's program read holds every filter stage to the program's limit: a program behind +/// ASCIIHex and Flate whose Flate stage would make 24 MiB stops at 16 MiB, charged that and what +/// the hex stage made, where the stages' total alone would let it make all 24 MiB. A program +/// within the limit behind the same two filters reads (see also +/// `an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit`). +#[test] +fn an_editors_program_read_holds_each_stage_to_its_limit() { + let mut doc = Document::new_empty(); + let flate = Stream::flate(Dict::new(), &vec![b' '; 24 << 20]); + let hex: Vec = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + let program = doc.add(Object::Stream(Stream::from_raw(dict, hex))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none()); + assert_eq!(usize::MAX / 2 - budget.remaining(), flate.raw.len() + MAX_SFNT); +} + +/// Each stage of a stream a font's metrics are read from is held to the stream's limit, as a +/// decode without an allowance holds it: a `/CIDToGIDMap` behind Flate and ASCIIHex whose Flate +/// stage makes hex text past the map's limit is unread, as `Stream::decoded_within` leaves it, +/// though the two stages together make less than twice the limit; with its hex text within the +/// limit it reads. +#[test] +fn each_stage_of_a_metrics_stream_is_held_to_its_limit() { + let read = |cids: usize| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..cids).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect(); + let hex: Vec = map.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + let stream = Stream::from_raw(dict, Stream::flate(Dict::new(), &hex).raw.to_vec()); + let unread = stream.decoded_within(MAX_CID_TO_GID).is_err(); + let map = Object::Ref(doc.add(Object::Stream(stream))); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + (Metrics::from_dict(&doc, &f).outlined_cids.is_empty(), unread) + }; + // 0.6 of the limit: its hex text, 1.2 of it, is past the limit; the stages make 1.8 of it. + assert_eq!(read(MAX_CID_TO_GID * 6 / 20), (true, true)); + assert_eq!(read(MAX_CID_TO_GID / 4), (false, false)); +} + +/// The editor's program is held to its limit as the metrics reads' streams are: a 12 MiB program +/// whose Flate data is about 8 MiB reads behind ASCIIHex and Flate as behind Flate alone, though +/// the two stages make about 20 MiB together. A program of the limit reads behind either, and one a +/// byte past it is unread behind either. +#[test] +fn an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit() { + let program = |len: usize, noise: usize| { + let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec(); + // Bytes Flate can't shrink, then zeros. + let mut x = 1u32; + program.extend((0..noise).map(|_| { + x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345); + x.to_be_bytes()[0] + })); + program.resize(len, 0); + program + }; + let read = |program: &[u8], hex: bool| { + let mut doc = Document::new_empty(); + let flate = Stream::flate(Dict::new(), program); + let stream = if hex { + let digits = b"0123456789ABCDEF"; + let mut data: Vec = flate.raw.iter().flat_map(|b| [digits[usize::from(b >> 4)], digits[usize::from(b & 15)]]).collect(); + data.push(b'>'); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, data) + } else { + flate + }; + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(doc.add(Object::Stream(stream)))); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + embedded_sfnt(&doc, &simple) + }; + let near = program(12 << 20, 8 << 20); + let full = program(MAX_SFNT, 0); + for hex in [false, true] { + assert_eq!(read(&near, hex).as_deref(), Some(near.as_slice()), "behind ASCIIHex too: {hex}"); + assert_eq!(read(&full, hex).as_deref(), Some(full.as_slice()), "behind ASCIIHex too: {hex}"); + assert!(read(&program(MAX_SFNT + 1, 0), hex).is_none(), "behind ASCIIHex too: {hex}"); + } +} + +/// `data` in rows of `columns` bytes behind Flate and a PNG predictor, each row tagged Up (the +/// difference from the row above), so read without the predictor it is garbled; with its +/// `/DecodeParms` given by reference or direct. +fn predicted(doc: &mut Document, data: &[u8], columns: usize, by_reference: bool) -> Object { + let mut above = vec![0u8; columns]; + let mut rows = Vec::new(); + for row in data.chunks(columns) { + rows.push(2); + rows.extend(row.iter().zip(&above).map(|(b, a)| b.wrapping_sub(*a))); + above[..row.len()].copy_from_slice(row); + } + let mut s = Stream::flate(Dict::new(), &rows); + let parms: Dict = + [(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(i64::try_from(columns).unwrap()))].into_iter().collect(); + let parms = if by_reference { Object::Ref(doc.add(Object::Dict(parms))) } else { Object::Dict(parms) }; + s.dict.set(b"DecodeParms".to_vec(), parms); + Object::Ref(doc.add(Object::Stream(s))) +} + +/// A stream whose `/DecodeParms` are given by reference reads as it does with them direct: a PNG +/// predictor's tag bytes are taken out of the map, the CMap and the editor's program, not left in. +#[test] +fn parameters_given_by_reference_are_read() { + let read = |by_reference: bool| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..1000u16).flat_map(u16::to_be_bytes).collect(); + let map = predicted(&mut doc, &map, 2, by_reference); + let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec(); + cmap.resize(48, b' '); + let unicode = predicted(&mut doc, &cmap, 16, by_reference); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + f.set(b"ToUnicode".to_vec(), unicode); + let m = Metrics::from_dict(&doc, &f); + (m.outlined_cids.clone(), m.text_of(1).map(Cow::into_owned)) + }; + let direct = read(false); + assert!(!direct.0.is_empty()); + assert_eq!(direct.1.as_deref(), Some("X")); + assert_eq!(read(true), direct); + let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec(); + program.resize(program.len().next_multiple_of(4), 0); + for by_reference in [false, true] { + let mut doc = Document::new_empty(); + let file = predicted(&mut doc, &program, 4, by_reference); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), file); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + assert_eq!(embedded_sfnt(&doc, &simple).as_deref(), Some(program.as_slice()), "by reference: {by_reference}"); + } +} + +/// Fonts that each name one large ToUnicode share the allowance they are read within: it pays for +/// as many readings as it holds, and the rest are left unread, where each reading used to decode +/// it afresh. +#[test] +fn fonts_naming_one_cmap_share_one_allowance() { + let mut doc = Document::new_empty(); + let mut cmap = b"1 beginbfchar <41> <0058> endbfchar".to_vec(); + cmap.resize(1_000_000, b' '); + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), &cmap))); + let fonts: Vec = (0..100) + .map(|_| { + font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("ToUnicode", Object::Ref(unicode))]) + }) + .collect(); + let mut budget = DecodeBudget::new(64 << 20, 0); + let read: Vec = fonts.iter().map(|f| Metrics::from_dict_within(&doc, f, &mut budget)).collect(); + let whole = read.iter().filter(|m| !m.past_allowance).count(); + assert_eq!(whole, (64 << 20) / cmap.len()); + assert!(read[..whole].iter().all(|m| m.text_of(0x41).as_deref() == Some("X"))); + assert!(read[whole..].iter().all(|m| m.text_of(0x41).as_deref() == Some("A")), "the encoding's meaning, unread past the allowance"); + assert_eq!(budget.remaining(), 0); +} + /// The names read once give what `Post::glyph_name` gives, for every glyph of a font with its /// own names (Inter's version 2.0 table), and none past the last glyph. #[test] @@ -388,6 +721,21 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() { ], ); assert_eq!(Metrics::from_dict(&doc, &f).decode(&codes), ""); + // The names are read within the allowance the metrics are read within: the program is paid + // for by the glyph-name reading, by the Mac OS Roman one (which finds the glyphs named and + // reads nothing) and for the widths. A byte short of the first, the names go unread. + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + let mut budget = DecodeBudget::new(3 * subset.len(), 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), symbolic); + assert_eq!(budget.remaining(), 0); + let short = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(subset.len() - 1, 0)); + assert!(short.past_allowance); + assert_ne!(short.decode(&codes), TEXT, "the names unread"); } /// The glyph list specification's `uni` and `u` forms spell their hex digits in uppercase @@ -603,6 +951,24 @@ fn type3_glyph_byte_limits_leave_honest_metrics() { assert_eq!((m.ascent, m.descent), (5.0, -2.0), "the /FontBBox, not the first glyph's box alone"); } +/// A Type 3 font's glyph descriptions are paid for from the allowance its metrics are read within, +/// each what it decodes to: one the allowance can't pay for is left unread, and the metrics say so. +#[test] +fn type3_glyph_boxes_are_paid_for_from_the_allowance() { + let mut doc = Document::new_empty(); + let procs = ["1 0 0 -20 1 300 d1", "1 0 0 0 1 400 d1"]; + let f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs); + let cost: usize = procs.iter().map(|p| p.len()).sum(); + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance); + assert_eq!((whole.ascent, whole.descent), (400.0, -20.0)); + let mut budget = DecodeBudget::new(cost, 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole); + assert_eq!(budget.remaining(), 0); + let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(cost - 1, 0)); + assert!(m.past_allowance, "a byte short, the second description is left unread"); +} + /// A Type 3 font measured from its glyphs' `d1` boxes keeps them over a descriptor's `/Ascent` /// and `/Descent`, which are optional for Type 3 (ISO 32000-2 Table 120). #[test] @@ -1142,7 +1508,7 @@ fn malformed_cmaps_never_panic() { let mut enc = Dict::new(); enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); - assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B")); + assert_eq!(unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))).text(u32::MAX).as_deref(), Some("B")); } #[test] @@ -1373,6 +1739,29 @@ fn low_codes_resolve_to_the_covering_range() { assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); } +#[test] +fn refused_subset_reads_do_not_encode_unverified_glyphs() { + let mut doc = Document::new_empty(); + let (mut f, sizes) = subset_type0(&mut doc, b""); + f.set(b"Encoding".to_vec(), Object::name("Identity-H")); + f.set(b"ToUnicode".to_vec(), Object::name("Identity-H")); + if let Some(Object::Array(descendants)) = f.get_mut(b"DescendantFonts") + && let Some(Object::Dict(descendant)) = descendants.first_mut() + { + descendant.set(b"DW".to_vec(), Object::Int(1000)); + } + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance && !whole.outlined_cids.is_empty()); + assert!(whole.encode("\u{ffff}").is_none()); + // Refuse the program, then the CID map after reading the complete program. + for allowance in [0, sizes[1]] { + let partial = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(allowance, 0)); + assert!(partial.past_allowance); + assert_eq!(partial.encode(""), Some(Vec::new())); + assert!(partial.encode("\u{ffff}").is_none(), "allowance {allowance} must not turn /DW into proof of a glyph"); + } +} + /// [`cmap_only_sfnt_named`] without a `post` table. fn cmap_only_sfnt(mac: Option<&[(u8, u8)]>, windows: &[(u16, u16)]) -> Vec { cmap_only_sfnt_named(mac, windows, false) @@ -1457,7 +1846,7 @@ fn a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman( descriptor.set(b"Flags".to_vec(), Object::Int(flags)); descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); - let u = unicode_map(&doc, &f, false); + let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))); codes.map(|c| u.text(c).unwrap_or_default().into_owned()) }; // `agrave` and `eacute` are glyphs 106 and 112 of the standard Macintosh order, `endash` and @@ -1492,7 +1881,7 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() { let mut all = vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]; all.extend(entries); let f = font(&mut doc, all); - let u = unicode_map(&doc, &f, false); + let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))); [0x88, 0x8E].map(|c| u.text(c).unwrap_or_default().into_owned()) }; assert_eq!(read(vec![]), ["à", "é"]); @@ -1512,8 +1901,40 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() { assert_eq!(read(vec![("Encoding", Object::Dict(notdef))]), ["à", ""], "/Differences naming .notdef"); } +/// The program reads that tell a symbolic TrueType font's codes by its glyph names and as Mac OS +/// Roman are each paid for from the allowance the metrics are read within: one it can't pay for is +/// left unread, and the metrics say so. #[test] -fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() { +fn the_symbolic_readings_of_a_program_are_paid_for() { + let program = cmap_only_sfnt(Some(&[(0x88, 106)]), &[(0xF088, 106)]); + let mut doc = Document::new_empty(); + let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &program))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(stream)); + // With `/Widths` the program isn't read for its advances: these are its only reads. + let entries = vec![ + ("Subtype", Object::name("TrueType")), + ("FontDescriptor", Object::Dict(descriptor)), + ("FirstChar", Object::Int(0x88)), + ("Widths", Object::Array(vec![Object::Int(500)])), + ]; + let f = font(&mut doc, entries); + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance); + assert_eq!(whole.text_of(0x88).as_deref(), Some("à")); + let mut budget = DecodeBudget::new(2 * program.len(), 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole); + assert_eq!(budget.remaining(), 0); + let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(2 * program.len() - 1, 0)); + assert!(m.past_allowance, "a byte short, the Mac OS Roman reading's read is left unread"); + assert_eq!(m.text_of(0x88), None); +} + +/// A Type 0 font over Inter's program without its `cmap`, Identity-H, whose `/CIDToGIDMap` sends +/// CID 17 to Inter's Ω and whose ToUnicode says code 17 is Ω; also the program object, the +/// program, Ω's glyph and what the ToUnicode and the map decode to. +fn cmapless_composite(doc: &mut Document) -> (Dict, pdfcraft_cos::ObjRef, Vec, skrifa::GlyphId, [usize; 2]) { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); let expected = skrifa::FontRef::new(original).unwrap().charmap().map('Ω').unwrap(); @@ -1525,7 +1946,6 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without subset[12 + index * 16..28 + index * 16].copy_from_slice(record); } assert!(skrifa::FontRef::new(&subset).unwrap().cmap().is_err()); - let mut doc = Document::new_empty(); let program = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &subset))); let mut descriptor = Dict::new(); descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); @@ -1535,9 +1955,10 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without let mut descendant = Dict::new(); descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); descendant.set(b"CIDToGIDMap".to_vec(), Object::Ref(gids)); - let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"1 beginbfchar <0011> <03a9> endbfchar"))); + let cmap = b"1 beginbfchar <0011> <03a9> endbfchar"; + let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), cmap))); let source = font( - &mut doc, + doc, vec![ ("Subtype", Object::name("Type0")), ("Encoding", Object::name("Identity-H")), @@ -1545,6 +1966,14 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without ("ToUnicode", Object::Ref(unicode)), ], ); + (source, program, subset, expected, [cmap.len(), 36]) +} + +#[test] +fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (source, program, subset, expected, _) = cmapless_composite(&mut doc); let editor = embedded_sfnt(&doc, &source).expect("the source outlines must be usable in the editor"); assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); let shaped = crate::source_shaping::shape(&editor, "Ω").expect("the rebuilt cmap must work in the actual shaping engine too"); @@ -1552,3 +1981,74 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without let Object::Stream(saved) = doc.get(program).as_ref().clone() else { panic!("fixture") }; assert_eq!(saved.decoded().unwrap(), subset, "the source PDF font program stays byte-for-byte intact"); } + +/// The metrics read that rebuilds a composite subset's cmap is paid for from the editor's +/// allowance, as a simple font's is: the program, its ToUnicode, then its `/CIDToGIDMap`. With the +/// ToUnicode left unread no copy is adapted, though what is left would pay for the map. +#[test] +fn adapting_a_composite_subsets_cmap_is_paid_for() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (source, _, subset, expected, [cmap, map]) = cmapless_composite(&mut doc); + let cost = subset.len() + cmap + map; + let mut budget = DecodeBudget::new(cost, 0); + let editor = embedded_sfnt_within(&doc, &source, &mut budget).unwrap(); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); + assert_eq!(budget.remaining(), 0); + assert!(cmap > map); + assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(subset.len() + cmap - 1, 0)).is_none()); +} + +/// A composite font whose `/Encoding` isn't Identity-H can't have its cmap rebuilt +/// (`composite_glyph_maps` reads only Identity-H codes), so the editor reads its program and no +/// more: its ToUnicode and `/CIDToGIDMap` are left for what is left of the allowance. +#[test] +fn a_composite_font_that_cant_be_adapted_spends_only_its_program() { + let mut doc = Document::new_empty(); + let (source, _, subset, _, [cmap, map]) = cmapless_composite(&mut doc); + for encoding in ["Identity-V", "UniJIS-UCS2-H"] { + let mut f = source.clone(); + f.set(b"Encoding".to_vec(), Object::name(encoding)); + let mut budget = DecodeBudget::new(subset.len() + cmap + map, 0); + assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none(), "{encoding}"); + assert_eq!(budget.remaining(), cmap + map, "{encoding}"); + } +} + +/// A composite font's `/Encoding` given through a reference is the name it refers to, as the +/// metrics read it: Identity-H by reference is adapted as Identity-H written directly is. +#[test] +fn a_composite_fonts_encoding_given_by_reference_is_read() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (mut source, _, _, expected, _) = cmapless_composite(&mut doc); + let identity = doc.add(Object::name("Identity-H")); + source.set(b"Encoding".to_vec(), Object::Ref(identity)); + assert_eq!(Metrics::from_dict(&doc, &source).code_len, 2); + let editor = embedded_sfnt(&doc, &source).expect("Identity-H by reference is adapted"); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); +} + +/// The `/CIDToGIDMap` a composite subset's cmap is rebuilt from reads as the font's other streams +/// do: a full map, which compresses well, reads behind ASCIIHex and Flate, though the hex stage's +/// output comes on top of the map. +#[test] +fn a_full_cid_map_rebuilds_a_composite_subsets_cmap_behind_two_filters() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (mut source, _, _, expected, _) = cmapless_composite(&mut doc); + let mut map = vec![0u8; MAX_CID_TO_GID]; + map[34..36].copy_from_slice(&(expected.to_u32() as u16).to_be_bytes()); + let flate = Stream::flate(Dict::new(), &map); + let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + let map = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, hex.into_bytes())))); + let Some(Object::Array(mut descendants)) = source.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + source.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + let editor = embedded_sfnt(&doc, &source).expect("the map behind two filters is read"); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); +} diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index ac151a5a18..84075f87e8 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -7,7 +7,7 @@ //! does (a mark takes none); correct maps and unrelated fonts are never changed. No font //! artwork is bundled. -use super::{Dict, Document, Metrics, dict, only_char, unicode_map}; +use super::{Dict, Document, Metrics, Reads, dict, identity_h, only_char, unicode_map}; use pdfcraft_cos::{DecodeBudget, Object}; use std::collections::BTreeMap; @@ -19,15 +19,18 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option Option> { let descriptor = dict(doc, descendant.get(b"FontDescriptor"))?; let program = doc.resolve(descriptor.get(b"FontFile2")?); let Object::Stream(program) = program.as_ref() else { return None }; - DecodeBudget::new(super::MAX_SFNT, 0).decode_within(&doc.with_direct_parms(program), super::MAX_SFNT).ok() + DecodeBudget::new(super::MAX_SFNT, 0).decode_with_damage(doc, program, super::MAX_SFNT).ok().map(|(data, _)| data) } /// Source glyph positions and the original PDF character codes that draw them. Empty codes @@ -190,14 +193,15 @@ pub(super) fn composite_glyph_maps( budget: &mut DecodeBudget, ) -> Option<(BTreeMap, BTreeMap)> { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; - if font.name(b"Encoding") != Some(b"Identity-H") { + if !identity_h(doc, font) { return None; } let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?; let cid_map = match descendant.get(b"CIDToGIDMap").map(|m| doc.resolve(m)).as_deref() { None => None, Some(Object::Name(n)) if n == b"Identity" => None, - Some(Object::Stream(s)) => Some(budget.decode_within(&doc.with_direct_parms(s), 1 << 17).ok()?), + // Read as the font's other streams are, a map behind several filters too. + Some(Object::Stream(s)) => Some(Reads::new(doc, budget).decode(s, super::MAX_CID_TO_GID)?), _ => return None, }; let face = skrifa::FontRef::new(bytes).ok()?; @@ -294,13 +298,17 @@ impl LegacyThaiFont { /// Thai glyphs are not in the legacy order: the nominal and alternate marks this shaper /// assumes can't be trusted there, so it is shaped as an ordinary source font. /// - /// Its streams are decoded against `budget`, which the caller shares between every font it - /// reads: what they keep is the programs decoded and the editor cmaps added to them. + /// Its streams, its metrics' too, are decoded against `budget`, which the caller shares + /// between every font it reads: what they keep is the programs decoded and the editor cmaps + /// added to them. Metrics read past it adapt nothing: the font is refused. pub fn from_pdf(doc: &Document, font: &Dict, space: f64, budget: &mut DecodeBudget) -> Result, String> { - let m = Metrics::from_dict(doc, font); + let m = Metrics::from_dict_within(doc, font, budget); if !m.base_font.rsplit('+').next().unwrap_or(&m.base_font).starts_with("THSarabunPSK") { return Ok(None); } + if m.past_allowance { + return Err("the source Thai font's metrics are past the edit's allowance".into()); + } if !space.is_finite() || space < 0.0 { return Err("the source space width is invalid".into()); } @@ -528,6 +536,46 @@ mod tests { /// An intact legacy map: nominal Thai and two tone alternates. const INTACT: &[u8] = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar"; + /// A THSarabunPSK subset whose own cmap is Latin-only has its Thai mappings rebuilt from its + /// `/CIDToGIDMap`. With that map left unread for want of the editor's allowance, the editor + /// lists no copy, rather than the program without its Thai mappings. + #[test] + fn a_map_left_unread_for_the_allowance_lists_no_copy() { + let mut doc = Document::new_empty(); + let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let file = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), program)))); + let map: Vec = (0u16..512).flat_map(u16::to_be_bytes).collect(); + let cid_to_gid = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &map)))); + let font = thai_font_over(&mut doc, file, cid_to_gid, INTACT); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut budget).is_some()); + let cost = usize::MAX / 2 - budget.remaining(); + // The map the cmap is rebuilt from is read last: a byte short of the cost, it is refused. + let mut short = DecodeBudget::new(cost - 1, 0); + assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut short).is_none(), "the program is listed without its Thai mappings"); + assert_eq!(short.refused(), 1); + } + + /// `LegacyThaiFont::from_pdf` reads its metrics within the edit's allowance too: the subset's + /// program, for its outlined glyphs, and its ToUnicode are paid for on top of what the + /// editor's read of it pays. Metrics read past the allowance refuse the font before its + /// program is read. + #[test] + fn a_legacy_fonts_metrics_are_paid_for_from_the_edits_allowance() { + let mut doc = Document::new_empty(); + let program = thai_ordered_program(); + let font = thai_font(&mut doc, &program, INTACT); + let mut editor = DecodeBudget::new(usize::MAX / 2, 0); + assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut editor).is_some()); + let editor_cost = usize::MAX / 2 - editor.remaining(); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut budget).unwrap(); + assert_eq!(usize::MAX / 2 - budget.remaining(), editor_cost + program.len() + INTACT.len()); + let mut short = DecodeBudget::new(program.len() + INTACT.len() - 1, 0); + let error = LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut short).err().unwrap_or_default(); + assert!(error.contains("past the edit's allowance"), "{error}"); + } + /// The exporter's damaged map: intact anchors, Latin/control destinations for the other Thai /// codes, and the oversized range first. const DAMAGED: &[u8] = b"begincmap 1 begincodespacerange <0000> endcodespacerange \ @@ -547,6 +595,32 @@ mod tests { let recovered: HashMap<_, _> = recovered_thai_map(&doc, &font).unwrap().into_iter().collect(); assert_eq!(recovered[&262], 'ั'); assert_eq!(recovered[&297], '๘'); + // `/Encoding` given through a reference is the name it refers to. + let mut indirect = font.clone(); + indirect.set(b"Encoding".to_vec(), Object::Ref(doc.add(Object::name("Identity-H")))); + assert_eq!(recovered_thai_map(&doc, &indirect), recovered_thai_map(&doc, &font)); + // `/CIDToGIDMap` given through a reference is the name it refers to, and a missing one is + // taken as Identity, the default of PDF 1.x (ISO 32000-1), as the renderer takes it. + let with_map = |map: Option| { + let mut f = font.clone(); + let Some(Object::Array(desc)) = f.get(b"DescendantFonts").cloned() else { panic!("fixture") }; + let Some(Object::Dict(mut desc)) = desc.first().cloned() else { panic!("fixture") }; + match map { + Some(map) => desc.set(b"CIDToGIDMap".to_vec(), map), + None => { + desc.remove(b"CIDToGIDMap"); + } + } + f.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)])); + f + }; + let by_reference = with_map(Some(Object::Ref(doc.add(Object::name("Identity"))))); + assert_eq!(recovered_thai_map(&doc, &by_reference), recovered_thai_map(&doc, &font), "a map by reference"); + assert_eq!(recovered_thai_map(&doc, &with_map(None)), recovered_thai_map(&doc, &font), "a missing map"); + // Any other map is refused: one that moves glyphs, and another name. + let moved = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[0, 1, 0, 0])))); + assert_eq!(recovered_thai_map(&doc, &with_map(Some(moved))), None, "a map that moves glyphs"); + assert_eq!(recovered_thai_map(&doc, &with_map(Some(Object::name("Foo")))), None, "another name"); // 259–261 are ฮ, ฯ and ะ, between the last anchor (อ, 258) and the first mark: a font that // put Mai Han-akat one glyph earlier passes every anchor and only this check sees it. let mut shifted = thai_ordered_program(); diff --git a/crates/fonts/tests/stream_views.rs b/crates/fonts/tests/stream_views.rs new file mode 100644 index 0000000000..de210637ae --- /dev/null +++ b/crates/fonts/tests/stream_views.rs @@ -0,0 +1,62 @@ +//! Memory regression for the font reads charged to an allowance: a stream whose `/DecodeParms` +//! is given by reference is read through a view that shares its data and copies no more of its +//! dictionary than its filters and parameters, not through a copy of the whole stream. A test +//! binary of its own, so that the one test is all the process runs while its peak is measured. + +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_fonts::pdf::{Metrics, embedded_sfnt_within}; + +/// How much the process's peak resident memory grows, in KiB, while `f` runs. `None` where the +/// peak can't be reset (`/proc/self/clear_refs` is Linux's). +fn peak_growth(f: impl FnOnce() -> T) -> Option<(T, u64)> { + fn peak_kib() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + status.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.split_whitespace().next()?.parse().ok() + } + std::fs::write("/proc/self/clear_refs", "5").ok()?; + let before = peak_kib()?; + let out = f(); + Some((out, peak_kib()?.saturating_sub(before))) +} + +/// A stream of `data`, its `/DecodeParms` the reference `parms`, its dictionary holding `junk`. +fn stream(doc: &mut Document, data: &[u8], parms: ObjRef, junk: &Object) -> Object { + let mut s = Stream::flate(Dict::new(), data); + s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + s.dict.set(b"Junk".to_vec(), junk.clone()); + Object::Ref(doc.add(Object::Stream(s))) +} + +/// A font whose program and ToUnicode each hold an unrelated array of a million numbers in their +/// dictionaries, with `/DecodeParms` given by reference, read eight times within an allowance by +/// the editor's program read and by the metrics read: neither copies an array. A read through a +/// copy of the stream would add about as much as one array holds. +#[test] +fn reading_a_fonts_streams_copies_no_dictionary() { + let junk = Object::Array(vec![Object::Int(0); 1 << 20]); + let array_kib = (std::mem::size_of::() << 20) as u64 >> 10; + let mut doc = Document::new_empty(); + let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(1))]))); + let program = stream(&mut doc, include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), parms, &junk); + let unicode = stream(&mut doc, b"1 beginbfchar <41> <0058> endbfchar", parms, &junk); + drop(junk); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), program); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("TrueType")); + font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + font.set(b"ToUnicode".to_vec(), unicode); + let Some(((), growth)) = peak_growth(|| { + for _ in 0..8 { + let mut budget = DecodeBudget::new(64 << 20, 0); + assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_some()); + let m = Metrics::from_dict_within(&doc, &font, &mut budget); + assert_eq!(m.text_of(0x41).as_deref(), Some("X")); + } + }) else { + eprintln!("the peak can't be measured here"); + return; + }; + assert!(growth < array_kib / 2, "the peak grew {growth} KiB, an array is {array_kib} KiB"); +} diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 2ea52df387..12f3ee5ea9 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -98,6 +98,12 @@ pub enum Calculate { Simple { op: CalcOp, fields: Vec }, /// Simplified field notation, e.g. `Price * Quantity`. Notation(String), + /// A custom calculation script, written as given (Field Properties ▸ Calculate ▸ Custom + /// calculation script). Reading a field never gives this: a script that isn't one of the + /// forms above stays in [`Actions::unsupported`] and [`Scripts::calculate`], where the + /// JavaScript engine runs it. Authoring writes it back byte for byte; a blank one writes no + /// script. + Custom(String), } /// A field's recognised scripts. diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index 66738246c2..b7f9bd4c4c 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -445,6 +445,7 @@ impl Calculate { match self { Calculate::None => Vec::new(), Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), + Calculate::Custom(js) => calculation_reads(js), Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { Notation::Name(name) => Some(name), _ => None, @@ -620,7 +621,8 @@ const MAX_EXPANDED_VALUES: usize = 1 << 20; pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { let num = |s: &String| make_number(s).unwrap_or(0.0); match c { - Calculate::None => None, + // A custom script is the JavaScript engine's to run. + Calculate::None | Calculate::Custom(_) => None, // A product of no values is 0, as an empty sum, average, minimum or maximum is, not the // empty product 1: a blank quantity × price row reads 0, and a total over the rows // doesn't count it as 1. (The form's `values` leaves out a field that has no value.) diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index 8212da5bf2..3af5e2dca3 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -383,9 +383,12 @@ fn notation_text(src: &str) -> &str { } } +/// The `/AA /C` JavaScript that stores `c`; `None` when it stores none (no calculation, or a +/// blank custom script). pub fn calculate_js(c: &Calculate) -> Option { match c { Calculate::None => None, + Calculate::Custom(js) => (!js.trim().is_empty()).then(|| js.clone()), Calculate::Simple { op, fields } => Some(format!( "AFSimple_Calculate({}, new Array({}));", js_str(op.code()), diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index b4b6452a3c..d19d9f2a5f 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -487,6 +487,20 @@ fn destructured_for_of_targets_keep_regex_iterables_opaque() { } } +/// A custom calculation script (#6630) is written as given, a blank one as no script; it reads the +/// fields it names, and running it is the JavaScript engine's, not the AF calculator's. +#[test] +fn a_custom_calculation_script_is_kept_as_written() { + let js = "event.value = this.getField(\"a\").value * getField(\"b\").value; // getField(\"c\")"; + let c = Calculate::Custom(js.into()); + assert_eq!(calculate_js(&c).as_deref(), Some(js)); + for blank in ["", " \r\n\t"] { + assert_eq!(calculate_js(&Calculate::Custom(blank.into())), None, "{blank:?}"); + } + assert_eq!(c.reads(), ["a", "b"]); + assert_eq!(calculate(&c, &|_| vec!["2".into()]), None); +} + #[test] fn simple_calculations_fold_a_repeated_group_without_collecting_it() { let group = |_: &str| vec!["2".to_string(); 3]; diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 715db946e4..ee6eef518b 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -21,7 +21,7 @@ pub(crate) use tree::ancestor_sets; pub use edit::set_props; pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field}; pub(crate) use look::button_appearance; -pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, set_button_icon}; +pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, redraw_fields, set_button_icon}; pub(crate) use tree::{ensure_form, form_fonts_as_objects}; /// The smallest width and height of a field's widget, in points: a smaller one is refused. diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 2c727779eb..ea718369bf 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -94,6 +94,21 @@ fn button_export_options(f: &Field, index: usize, value: &str) -> Result Option { + let js = crate::af::calculate_js(c)?; + let text = match c { + crate::af::Calculate::Custom(_) => PdfString::text(&js), + _ => PdfString::literal(js.into_bytes()), + }; + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), text); + Some(Object::Dict(a)) +} + /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { @@ -328,10 +343,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result<(), FormError> { /// `FormError::NoSuchField` when no field is named `name`. pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result<(), FormError> { let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + redraw_widgets_of(doc, &f, only) +} + +/// [`redraw_field`] for each field [`fields`] lists that `keep` accepts. The form's fields are +/// read once: drawing a field changes only its own widgets' appearances, so the list stays true +/// for the rest. Looking each one up by name instead reads the whole form again for every field, +/// which grows with the square of the field count (a laid out XFA form with a thousand rows holds +/// thousands of fields). A failure names the field it stopped at. +pub fn redraw_fields(doc: &mut Document, mut keep: impl FnMut(&Document, &Field) -> bool) -> Result<(), FormError> { + for f in fields(doc) { + if keep(doc, &f) { + redraw_widgets_of(doc, &f, None).map_err(|e| FormError::Invalid(format!("{}: {e}", f.name)))?; + } + } + Ok(()) +} + +/// [`redraw_widgets`] for the field `f`, as `fields(doc)` read it. +fn redraw_widgets_of(doc: &mut Document, f: &Field, only: Option) -> Result<(), FormError> { let mut undrawn = false; for (index, w) in f.widgets.iter().enumerate() { if only.is_some_and(|only| only != index) { @@ -323,7 +342,7 @@ pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option let mut missing = None; let ap = match f.kind { FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let (mut s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); + let (mut s, m) = appearance::field_appearance_drawn(doc, f, w, &f.value, true); missing = m; // Thai glyphs are streams of their own. pdfcraft_fonts::shaped_text::store_streams(doc, &mut s.dict); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 236310f268..6a35956ed0 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -27,7 +27,7 @@ pub mod table; pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions}; pub use author::{ BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, - delete_field_object, delete_widget, duplicate_field, look, redraw_field, set_button_icon, set_props, + delete_field_object, delete_widget, duplicate_field, look, redraw_field, redraw_fields, set_button_icon, set_props, }; pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order}; pub use scripting::{ diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b69e24d8dc..6f3eda2be4 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1726,6 +1726,70 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() { assert_eq!(field(&fields(&doc), "total").value, ["ran"]); } +/// The `/JS` string of field `name`'s `/AA /C` action, as stored. +fn calculate_action_js(doc: &Document, name: &str) -> Option { + let f = field(&fields(doc), name).obj; + let aa = doc.get(f).as_dict().and_then(|d| d.get(b"AA").cloned()).map(|a| doc.resolve(&a))?; + let c = aa.as_dict().and_then(|aa| aa.get(b"C").cloned()).map(|c| doc.resolve(&c))?; + Some(c.as_dict().and_then(|c| c.get(b"JS")).and_then(|s| s.as_string()).expect("/JS is a string").clone()) +} + +/// Field Properties ▸ Calculate ▸ Custom calculation script (#6630): the script is written as +/// given, as a text string, and reads back as the field's custom script, in the calculation +/// order; a blank one removes the script and the field's place in the order. +#[test] +fn a_custom_calculation_script_is_written_as_given_and_a_blank_one_removes_it() { + use crate::af::Calculate; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &text, Some("codice")).unwrap(); + add_field(&mut doc, 0, [50.0, 660.0, 250.0, 680.0], &text, Some("barcode")).unwrap(); + let js = "// Code 39: tra asterischi, così\r\nevent.value = \"*\" + this.getField(\"codice\").value + \"*\";"; + let custom = |js: &str| FieldProps { calculate: Some(Calculate::Custom(js.into())), ..FieldProps::default() }; + set_props(&mut doc, "barcode", &custom(js)).unwrap(); + for doc in [doc.clone(), reopen(&doc)] { + let all = fields(&doc); + let actions = &field(&all, "barcode").actions; + assert_eq!((&actions.calculate, actions.scripts.calculate.as_deref()), (&Calculate::None, Some(js)), "read back as a script"); + assert!(actions.unsupported.contains(&"calculate")); + let stored = calculate_action_js(&doc, "barcode").expect("an /AA /C action"); + assert_eq!(stored.bytes, pdfcraft_cos::PdfString::text(js).bytes, "a text string, so ì isn't UTF-8 bytes another viewer reads as ì's pieces"); + let order = calculation_order(&doc, &all); + assert_eq!(order.fields.iter().map(|f| (f.name.as_str(), f.listed)).collect::>(), [("barcode", true)]); + assert_eq!(order.fields[0].reads, ["codice"]); + } + for blank in ["", " \n\t"] { + let mut doc = doc.clone(); + set_props(&mut doc, "barcode", &custom(blank)).unwrap(); + let all = fields(&doc); + let actions = &field(&all, "barcode").actions; + assert_eq!((&actions.calculate, actions.scripts.calculate.as_deref()), (&Calculate::None, None), "{blank:?}"); + assert!(calculate_action_js(&doc, "barcode").is_none(), "{blank:?} writes no /C"); + assert!(calculation_order(&doc, &all).fields.is_empty(), "{blank:?} leaves the calculation order"); + } +} + +/// A sum and a notation are still written as the AF calls they are, byte for byte, beside the +/// custom script that is written as a text string (#6630). +#[test] +fn a_sum_and_a_notation_are_written_as_their_af_calls() { + use crate::af::{CalcOp, Calculate, calculate_js}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["a", "b", "total"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let sum = Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] }; + for c in [sum, Calculate::Notation("a * b".into())] { + set_props(&mut doc, "total", &FieldProps { calculate: Some(c.clone()), ..FieldProps::default() }).unwrap(); + let js = calculate_js(&c).unwrap(); + assert_eq!(calculate_action_js(&doc, "total").map(|s| s.bytes), Some(js.into_bytes()), "{c:?}"); + let all = fields(&reopen(&doc)); + assert_eq!((&field(&all, "total").actions.calculate, &field(&all, "total").actions.scripts.calculate), (&c, &None)); + } +} + /// An unsupported first AF call keeps the entire script and reaches the caller's interpreter. #[test] fn unsupported_first_validation_call_preserves_custom_script_dispatch() { @@ -5721,6 +5785,34 @@ fn set_values_reads_the_form_once_not_per_value() { assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); } +/// Redrawing every field of a large form reads the form once, not once per field: a laid out XFA +/// form with a thousand rows holds thousands of fields, and reading them all again for each one +/// took 15 s a relayout. The fields `keep` turns down keep the appearance they had. +#[test] +fn redraw_fields_reads_the_form_once_however_many_fields_it_holds() { + for n in [250u32, 1000] { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + ]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", i + 5)).collect(); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" "))); + objs.push(format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) >>", kids.join(" "))); + for i in 0..n { + objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /V (v{i}) /Rect [50 0 250 10] /P 3 0 R >>")); + } + let mut doc = document(&objs); + let before = form_reads(); + redraw_fields(&mut doc, |_, f| f.name != "f0").unwrap(); + assert_eq!(form_reads() - before, 1, "{n} fields"); + let all = fields(&doc); + let has_ap = |name: &str| field(&all, name).widgets.iter().all(|w| doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))); + assert!(!has_ap("f0"), "turned down: left as it was"); + assert!((1..n).all(|i| has_ap(&format!("f{i}"))), "{n} fields"); + assert!(ap(&doc, &field(&all, &format!("f{}", n - 1)).widgets[0]).contains(&format!("(v{})", n - 1))); + } +} + /// A radio group `size` with the buttons Small (on), Medium and Large, a per-widget `/Opt`, and /// the group's buttons in the calculation order; and a text field `name` with two widgets. fn radio_group_form() -> Document { diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index f4792ba64b..7e171bc369 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -100,6 +100,9 @@ struct Interp<'h> { /// burning a core to the loop limit). #[cfg(not(target_arch = "wasm32"))] deadline: Option, + /// `deadline` passed and stopped the script: the run reports as abandoned. + #[cfg(not(target_arch = "wasm32"))] + out_of_time: bool, } type R = Result; @@ -151,6 +154,8 @@ pub(crate) fn run_formcalc_at( exited: false, #[cfg(not(target_arch = "wasm32"))] deadline: deadline.and_then(|d| std::time::Instant::now().checked_add(d)), + #[cfg(not(target_arch = "wasm32"))] + out_of_time: false, }; let mut out = XfaOutcome::default(); match interp.block(&body) { @@ -165,6 +170,19 @@ pub(crate) fn run_formcalc_at( } Err(e) => out.error = Some(e), } + // The caller stops waiting at the same time limit, so which of the two notices first is + // down to the scheduler. The outcome must not depend on it: stopped by the clock, the run + // reports what an abandoned one does, the error and no changes. Keeping the changes made + // it a race: a loop adding rows until the clock stopped it left the form unchanged on one + // run and 1000 rows longer on the next (each laid out again, tens of seconds). + #[cfg(not(target_arch = "wasm32"))] + if let (true, Some(d)) = (interp.out_of_time, deadline) { + return XfaOutcome { + error: Some(format!("the script ran longer than {:.1} s and was abandoned", d.as_secs_f64())), + abandoned: true, + ..Default::default() + }; + } out.notes = host.notes(); out.effects = host.effects; out.console = host.console; diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs index 5f279ea4f6..26de7ba9eb 100644 --- a/crates/js/src/formcalc/access.rs +++ b/crates/js/src/formcalc/access.rs @@ -25,6 +25,7 @@ impl Interp<'_> { && let Some(d) = self.deadline && std::time::Instant::now() > d { + self.out_of_time = true; return Err(RAN_TOO_LONG.into()); } #[cfg(target_arch = "wasm32")] diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 297e7604d1..3550257b9e 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -4,7 +4,7 @@ use super::host::{arg, function, host, s, text, text_line}; use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request}; use super::{ Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer, - js_string, + error, js_string, }; // ── app, util, console ────────────────────────────────────────────────────────────────────── @@ -120,6 +120,36 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult<(String, bool)> { + let first = arg(args, 0); + // Any object of named parameters is read by key, one without `cName` too, so a missing name + // is refused rather than read as the object's text. + let named = first.as_object().filter(|o| !o.is_array() && !o.is_callable()); + let name = match &named { + Some(o) => text_line(&o.get(js_string!("cName"), ctx)?, ctx)?, + None => text_line(&first, ctx)?, + }; + if name.is_empty() { + return Err(error("exportDataObject needs cName, the attached file's name")); + } + let launch = match &named { + Some(o) => o.get(js_string!("nLaunch"), ctx)?, + None => arg(args, 3), + }; + let launch = if launch.is_undefined() || launch.is_null() { 0.0 } else { launch.to_number(ctx)? }; + Ok((name, launch == 1.0 || launch == 2.0)) +} + +/// `this.exportDataObject(…)`: asks the viewer to open or save an attached file. +fn export_data_object(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let (name, open) = data_object(args, ctx)?; + host(ctx)?.borrow_mut().request(Request::OpenAttachment { name, open }); + Ok(JsValue::undefined()) +} + /// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print /// and Save As. Other items do nothing. pub(crate) fn menu_item(name: &str) -> Option { @@ -199,11 +229,11 @@ fn color_object(ctx: &mut Context) -> JsObject { const XFA_VERSION: f64 = 3.5; /// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, -/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, -/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and -/// `color` objects. +/// `print`, `submitForm`, `exportDataObject` and others) and properties (`numFields`, `pageNum`, +/// `numPages`, `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, +/// `display` and `color` objects. pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { - let globals: [(&str, usize, NativeFunction); 9] = [ + let globals: [(&str, usize, NativeFunction); 10] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), @@ -213,6 +243,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { ("calculateNow", 0, NativeFunction::from_fn_ptr(noop)), ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), ("dirty", 0, NativeFunction::from_fn_ptr(noop)), + ("exportDataObject", 4, NativeFunction::from_fn_ptr(export_data_object)), ]; for (name, len, f) in globals { ctx.register_global_callable(JsString::from(name), len, f)?; diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 1419ba2a86..68d581f093 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -68,6 +68,7 @@ impl Host { let bytes = match &r { Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), + Request::OpenAttachment { name, .. } => name.len(), _ => 0, }; let kept = self.room(bytes); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 0bf4032efd..7948c06120 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -8,7 +8,7 @@ //! - `event`: `value`, `change`, `willCommit`, `rc`, `name`, `type`, `target`, `targetName`. //! - the document (`this`, also the global object): `getField`, `numFields`, //! `getNthFieldName`, `numPages`, `pageNum`, `documentFileName`, `info`, `resetForm`, -//! `calculateNow`, `print`, `submitForm`, `mailDoc`. +//! `calculateNow`, `print`, `submitForm`, `mailDoc`, `exportDataObject`. //! - Field objects: `name`, `type`, `value`, `valueAsString`, `defaultValue`, `readonly`, //! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`, //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index d3195e6a5a..86a6945846 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -241,6 +241,12 @@ pub enum Request { Beep, /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). SaveAs, + /// `this.exportDataObject(cName, …, nLaunch)`: the attached file `name`, to open (`nLaunch` + /// 1 or 2) or to save (0). + OpenAttachment { + name: String, + open: bool, + }, /// `app.runtimeHighlight = on`: highlight the form's fields, or stop. Highlight(bool), /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 72a88ac9f3..c726f78af9 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1637,7 +1637,21 @@ $host.gotoURL("https://example.org")"#, Limits { loop_iterations: u64::MAX, recursion: 32 }, Some(std::time::Duration::ZERO), ); - assert!(o.error.unwrap().contains("too long")); + assert!(o.abandoned && o.error.as_deref().is_some_and(|e| e.contains("ran longer")), "{o:?}"); + // Stopped by its clock, a script keeps none of its changes, as when the caller stops + // waiting first: the outcome is the same whichever side notices. A loop adding rows + // until the clock stops it kept up to 1000 new rows when the script noticed first. + let o = crate::formcalc::run_formcalc_at( + "while (1) do table._row.addInstance(1) endwhile", + &ev, + &doc, + std::sync::Arc::new(form()), + false, + Limits { loop_iterations: u64::MAX, recursion: 32 }, + Some(std::time::Duration::from_millis(50)), + ); + assert!(o.abandoned && o.effects.is_empty() && o.notes.is_empty(), "{:?}, {} effects", o.error, o.effects.len()); + assert!(o.error.as_deref().is_some_and(|e| e.contains("ran longer")), "{:?}", o.error); assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); assert!(run_hostile_formcalc("Space(1e12)").error.is_none()); @@ -1885,6 +1899,16 @@ mod session { slow.timeout = std::time::Duration::from_millis(200); let o = s.run(slow); assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}"); + // Whether the script's clock or the session's wait notices first, a FormCalc loop + // adding rows until the time is up is abandoned with none of its rows, and the session + // is dead after it. + let mut s = XfaSession::new(XfaDoc::default()); + let mut slow = script("while (1) do table._row.addInstance(1) endwhile", true, "form1[0].page1[0].go[0]", "click"); + slow.limits = Limits { loop_iterations: u64::MAX, recursion: 32 }; + slow.timeout = std::time::Duration::from_millis(200); + let o = s.run(slow); + assert!(o.abandoned && o.effects.is_empty(), "{:?}, {} effects", o.error, o.effects.len()); + assert!(s.dead().is_some_and(|w| w.contains("abandoned"))); } } @@ -1924,6 +1948,61 @@ fn shared_field_snapshots_isolate_writes_errors_and_script_globals() { assert!(read.changed.is_empty()); } +mod data_objects { + use super::*; + use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa}; + + /// `exportDataObject`, as a document method or a bare global, asks to open the named attached + /// file when `nLaunch` is 1 or 2 and to save it otherwise; without `cName` it is an error. + #[test] + fn export_data_object_asks_to_open_or_save_an_attached_file() { + let o = go( + "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2}); exportDataObject('notes.txt'); \ + this.exportDataObject({cName: 'form.pdf', nLaunch: 1});", + &Event::field("Mouse Up", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!( + o.requests, + [ + Request::OpenAttachment { name: "Guide.pdf".into(), open: true }, + Request::OpenAttachment { name: "notes.txt".into(), open: false }, + Request::OpenAttachment { name: "form.pdf".into(), open: true }, + ] + ); + let o = go("this.exportDataObject({nLaunch: 2})", &Event::field("Mouse Up", "total", "")); + assert!(o.error.as_deref().is_some_and(|e| e.contains("cName")), "{:?}", o.error); + assert!(o.requests.is_empty()); + } + + /// Only `nLaunch` 1 and 2 open the file: other values (1.5, 3, a huge number) save it, as the + /// default 0 does. + #[test] + fn export_data_object_opens_only_for_n_launch_1_and_2() { + for launch in ["1.5", "3", "1e9", "-1"] { + let o = go(&format!("this.exportDataObject({{cName: 'Guide.pdf', nLaunch: {launch}}});"), &Event::field("Mouse Up", "total", "")); + assert_eq!(o.requests, [Request::OpenAttachment { name: "Guide.pdf".into(), open: false }], "nLaunch {launch}"); + } + } + + /// In an XFA script `event.target` is the document, so `exportDataObject` works on it, while + /// `xfa.event.target` is still the node the event happened on. + #[test] + fn in_xfa_scripts_event_target_is_the_document_and_xfa_event_target_the_node() { + let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() }; + let o = run_xfa( + "event.target.exportDataObject({cName: 'Guide.pdf', nLaunch: 2}); xfa.event.target.name", + &event, + &XfaDoc::default(), + &super::xfa_model::form(), + Limits::default(), + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("go"), "xfa.event.target is still the button"); + assert!(o.effects.contains(&XfaEffect::OpenAttachment { name: "Guide.pdf".into(), open: true }), "{:?}", o.effects); + } +} + mod runtime_highlight { use super::*; use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaNode, run_xfa}; diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 085262351e..ec8d7cc24d 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -15,6 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy}; use boa_engine::property::Attribute; use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; +use crate::globals::data_object; use crate::{Limits, Message, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; mod globals; diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs index 50647a3426..de57d7fd49 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -3,8 +3,8 @@ use super::instances::instance_manager; use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, payload, reset_names}; use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink}; use super::{ - Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, platform, printd, printf, printx, - s, scand, text, + Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, data_object, function, js_string, platform, printd, + printf, printx, s, scand, text, }; // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── @@ -15,6 +15,13 @@ fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { Ok(()) } +/// `event.target.exportDataObject(…)`: the document asks to open or save an attached file. +fn export_data_object(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let (name, open) = data_object(args, ctx)?; + effect(ctx, XfaEffect::OpenAttachment { name, open })?; + Ok(JsValue::undefined()) +} + /// `app.runtimeHighlight`: whether the viewer highlights form fields. fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.highlight)) @@ -67,13 +74,13 @@ fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::undefined()) } -/// `xfa.host.beep()`: asks to beep. +/// `xfa.host.beep()` and `app.beep()`: ask to beep. fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Beep)?; Ok(JsValue::undefined()) } -/// `xfa.host.gotoURL(url)`: asks to open the URL. +/// `xfa.host.gotoURL(url)` and `app.launchURL(url)`: ask to open the URL. fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = text(&arg(args, 0), ctx)?; effect(ctx, XfaEffect::LaunchUrl(u))?; @@ -114,50 +121,63 @@ fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult< Ok(JsValue::undefined()) } +/// `recalculate(…)` on a node or on `xfa`: records a request to recalculate the form. pub(super) fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Recalculate)?; Ok(JsValue::undefined()) } +/// `xfa.layout.relayout()`, `relayoutPageArea()` and `remerge()`: record a request to lay the +/// form out again. pub(super) fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Relayout)?; Ok(JsValue::undefined()) } +/// `console.println(…)`: adds the line, cut to [`MAX_LINE`] characters, to the run's console +/// output. fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = clip(text(&arg(args, 0), ctx)?); host(ctx)?.borrow_mut().print(m); Ok(JsValue::undefined()) } +/// `xfa.host.response(…)` and `app.response(…)`: always return `null`, as for a canceled dialog. fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { Ok(JsValue::null()) } +/// `xfa.layout.page(…)`: the current page, 1-based, whatever the argument. fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0)) } +/// `xfa.layout.absPage(…)`: the current page, 0-based, whatever the argument. fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) } +/// `xfa.layout.pageCount()` and `absPageCount()`: the document's page count. fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64)) } +/// The `xfa.layout` measures `h`, `w`, `x`, `y` and `pageSpan`: always 0. fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { Ok(JsValue::from(0.0)) } +/// `xfa.resolveNode(som)`, or `xfa.resolveNodes(som)` when `many`, resolving from the node the +/// script runs on. fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { let current = host(ctx)?.borrow().current; resolver(ctx, current, many) } /// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, -/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, -/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. +/// `resolveNode` and more; `xfa.event`'s target is that node), a separate global `event` whose +/// target is a document offering `exportDataObject`, `app`, `console`, `util`, and the nodes in +/// scope by name, with `_name` for each repeatable one's instance manager. pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { let rw = Attribute::CONFIGURABLE; let form = node_object(ctx, 0)?; @@ -207,24 +227,33 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current .function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1) .property(js_string!("ready"), JsValue::from(true), Attribute::READONLY) .build(); - let ev = ObjectInitializer::new(ctx) - .property(js_string!("name"), s(&event.activity), Attribute::all()) - .property(js_string!("newText"), s(&event.new_text), Attribute::all()) - .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) - .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) - .property(js_string!("change"), s(""), Attribute::all()) - .property(js_string!("target"), this_node.clone(), Attribute::all()) - .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) - .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) - .property(js_string!("shift"), JsValue::from(false), Attribute::all()) - .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) - .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) - .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) - .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) - .build(); + // Two event objects, as in Acrobat's XFA scripting: `xfa.event`, whose target is the form + // node the event happened on, and Acrobat's own `event`, whose target is the document + // (`event.target.exportDataObject(…)` is how forms open their attached files). + let acrobat_doc = + ObjectInitializer::new(ctx).function(NativeFunction::from_fn_ptr(export_data_object), js_string!("exportDataObject"), 4).build(); + let event_object = |ctx: &mut Context, target: JsValue| { + ObjectInitializer::new(ctx) + .property(js_string!("name"), s(&event.activity), Attribute::all()) + .property(js_string!("newText"), s(&event.new_text), Attribute::all()) + .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) + .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) + .property(js_string!("change"), s(""), Attribute::all()) + .property(js_string!("target"), target, Attribute::all()) + .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) + .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) + .property(js_string!("shift"), JsValue::from(false), Attribute::all()) + .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) + .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) + .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) + .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) + .build() + }; + let ev = event_object(ctx, this_node.clone()); + let acrobat_ev = event_object(ctx, acrobat_doc.into()); let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build(); let template = sink(ctx)?; let resolve_one = xfa_resolve(ctx, false)?; @@ -243,7 +272,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current .function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0) .build(); ctx.register_global_property(js_string!("xfa"), xfa, rw)?; - ctx.register_global_property(js_string!("event"), ev, rw)?; + ctx.register_global_property(js_string!("event"), acrobat_ev, rw)?; ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; let highlight_get = function(ctx, NativeFunction::from_fn_ptr(runtime_highlight)); let highlight_set = function(ctx, NativeFunction::from_fn_ptr(set_runtime_highlight)); diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs index 11468c04b3..212c2dd38c 100644 --- a/crates/js/src/xfa/model.rs +++ b/crates/js/src/xfa/model.rs @@ -104,6 +104,11 @@ pub enum XfaEffect { MessageBox(Message), /// `xfa.host.resetData()`: the named fields, or every field when empty. ResetData(Vec), + /// `event.target.exportDataObject(…)`: the attached file `name`, to open or to save. + OpenAttachment { + name: String, + open: bool, + }, /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. ShowPanel(crate::Panel), Print, @@ -217,6 +222,7 @@ pub(super) fn payload(e: &XfaEffect) -> usize { som.len().saturating_add(s.len()) } XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), + XfaEffect::OpenAttachment { name, .. } => name.len(), XfaEffect::MessageBox(m) => m.bytes(), XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), diff --git a/crates/js/src/xfa_session.rs b/crates/js/src/xfa_session.rs index 797f813332..88e0748625 100644 --- a/crates/js/src/xfa_session.rs +++ b/crates/js/src/xfa_session.rs @@ -179,7 +179,15 @@ mod native { loop { let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); match worker.from.recv_timeout(left) { - Ok(Reply::Outcome(o)) => return *o, + Ok(Reply::Outcome(o)) => { + // A FormCalc script its own clock stopped reports as abandoned: the + // session ends as it does when the wait runs out first. + if o.abandoned { + self.dead = o.error.clone(); + self.worker = None; + } + return *o; + } Ok(Reply::Started) => { // A second call changes nothing: the script's time has already begun. if !running { diff --git a/crates/ocr/src/tesseract.rs b/crates/ocr/src/tesseract.rs index 9fbcc574d8..a4e1407331 100644 --- a/crates/ocr/src/tesseract.rs +++ b/crates/ocr/src/tesseract.rs @@ -343,8 +343,17 @@ impl TesseractCli { // Known limit: only the direct child is killed at the timeout or on an early cap. A // wrapper script's children (grandchildren of this process) may survive the kill; the // cap and the timeout still bound what this run reads and how long it waits. + let mut command = std::process::Command::new(&self.program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + // `CREATE_NO_WINDOW`: tesseract is a console program and the app is not, so without + // it Windows opens a console window for every run, which flashes up during OCR. The + // output files above are unaffected. + command.creation_flags(0x0800_0000); + } let mut child = - std::process::Command::new(&self.program) + command .args(args) .stdin(std::process::Stdio::null()) .stdout(out_file) @@ -894,6 +903,28 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The program starts without a console window (Windows): the app has no console, so a + /// console program that isn't told otherwise gets a window of its own, which flashes up on + /// every OCR run. PowerShell stands in for tesseract and reports, through the same output file + /// a reading comes back in, its console's window and the processes attached to that console. + /// Both are checked, because a program started without the flag by a process that has a + /// console shares it instead, and that console may itself have no window. + #[cfg(windows)] + #[test] + fn the_program_starts_without_a_console_window() { + const PROBE: &str = r#"Add-Type -Namespace PdfCraftOcrProbe -Name Console -MemberDefinition '[DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow(); [DllImport("kernel32.dll")] public static extern uint GetConsoleProcessList(uint[] ids, uint count);' +$ids = New-Object uint32[] 256 +$n = [PdfCraftOcrProbe.Console]::GetConsoleProcessList($ids, 256) +"$([PdfCraftOcrProbe.Console]::GetConsoleWindow()) $($ids[0..($n-1)] -join ',')""#; + let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from); + let t = TesseractCli::new(root.join(r"System32\WindowsPowerShell\v1.0\powershell.exe")); + let text = t.run(&["-NoProfile", "-NonInteractive", "-Command", PROBE], t.timeout, t.max_output).unwrap(); + let (window, attached) = text.trim().split_once(' ').unwrap(); + assert_eq!(window, "0", "the program has a console window: {text}"); + let attached: Vec = attached.split(',').map(|id| id.parse().unwrap()).collect(); + assert!(!attached.is_empty() && !attached.contains(&std::process::id()), "the program shares this process's console: {text}"); + } + #[cfg(unix)] fn make_executable(p: &Path) { use std::os::unix::fs::PermissionsExt; diff --git a/crates/platform/src/owner_only.rs b/crates/platform/src/owner_only.rs index f7f4c20b5d..1151aba5d7 100644 --- a/crates/platform/src/owner_only.rs +++ b/crates/platform/src/owner_only.rs @@ -132,10 +132,19 @@ const ICACLS: &str = "icacls.exe"; /// A tool Windows ships, started by its full path (review on #3956). Started by bare name, a /// `whoami.exe`, `powershell.exe` or `icacls.exe` found first (next to the app, or earlier on the /// search path) would answer instead, and `whoami`'s answer is what every check here trusts. +/// +/// Each tool is a console program and the app is not, so without `CREATE_NO_WINDOW` Windows gives +/// every one of them a console window of its own, which flashes up while the app writes its +/// `--control` file. The flag doesn't touch the pipes `output()` reads the answer through. #[cfg(windows)] fn system_tool(relative: &str) -> std::process::Command { + use std::os::windows::process::CommandExt as _; + /// `CREATE_NO_WINDOW`, from the Windows process creation flags. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; let root = std::env::var_os("SystemRoot").map_or_else(|| std::path::PathBuf::from(r"C:\Windows"), std::path::PathBuf::from); - std::process::Command::new(root.join("System32").join(relative)) + let mut command = std::process::Command::new(root.join("System32").join(relative)); + command.creation_flags(CREATE_NO_WINDOW); + command } #[cfg(windows)] @@ -384,6 +393,28 @@ mod tests { } } + /// The tools start without a console window: the app has no console, so a tool that isn't + /// told otherwise gets a window of its own, which flashes up. The started PowerShell reports + /// its console's window and the processes attached to that console. Both are checked, because + /// a tool started without the flag by a process that has a console shares it instead, and that + /// console may itself have no window (a test run started by a service or an IDE). + #[cfg(windows)] + #[test] + fn the_tools_start_without_a_console_window() { + const PROBE: &str = r#"Add-Type -Namespace PdfCraftProbe -Name Console -MemberDefinition '[DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow(); [DllImport("kernel32.dll")] public static extern uint GetConsoleProcessList(uint[] ids, uint count);' +$ids = New-Object uint32[] 256 +$n = [PdfCraftProbe.Console]::GetConsoleProcessList($ids, 256) +"$([PdfCraftProbe.Console]::GetConsoleWindow()) $($ids[0..($n-1)] -join ',')""#; + let out = system_tool(POWERSHELL).args(["-NoProfile", "-NonInteractive", "-Command", PROBE]).output().unwrap(); + // The answer still comes back through the pipe. + let text = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{text} {}", String::from_utf8_lossy(&out.stderr)); + let (window, attached) = text.trim().split_once(' ').unwrap(); + assert_eq!(window, "0", "the tool has a console window: {text}"); + let attached: Vec = attached.split(',').map(|id| id.parse().unwrap()).collect(); + assert!(!attached.is_empty() && !attached.contains(&std::process::id()), "the tool shares this process's console: {text}"); + } + #[test] fn other_owners_readers_and_missing_lists_are_refused() { let other = "S-1-5-21-1-2-3-1002"; diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index d5a6d80b04..54a8a9508f 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -521,6 +521,41 @@ fn content_colour(data: &[u8], used: &mut DeviceColour) -> usize { read } +/// Records any device colour space named directly or used as the base or alternate space of a +/// colour-space object (`Name` or `Array` such as `[/Indexed base …]`, `[/Separation _ alt …]`, +/// or `[/DeviceN _ alt …]`). +fn mark_cs_object(doc: &Document, cs: &Object, used: &mut DeviceColour, depth: u8) { + if depth > MAX_DIRECT_DEPTH { + return; + } + match cs { + Object::Ref(r) => mark_cs_object(doc, &doc.get(*r), used, depth + 1), + Object::Name(n) => used.mark(n, false), + Object::Array(a) => { + let mut kind = a.first().map(|o| doc.resolve(o)); + for _ in 0..MAX_DIRECT_DEPTH { + let Some(&Object::Ref(r)) = kind.as_deref() else { break }; + kind = Some(doc.get(r)); + } + match kind.as_deref().and_then(Object::as_name) { + Some(n) if a.len() == 1 => used.mark(n, false), + Some(b"I" | b"Indexed" | b"Pattern") => { + if let Some(base) = a.get(1) { + mark_cs_object(doc, base, used, depth + 1); + } + } + Some(b"Separation" | b"DeviceN") => { + if let Some(alt) = a.get(2) { + mark_cs_object(doc, alt, used, depth + 1); + } + } + _ => {} + } + } + _ => {} + } +} + /// Whether any content or object uses a device colour space. fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> DeviceColour { let mut used = DeviceColour::default(); @@ -536,15 +571,15 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) { used.default_rgb |= spaces.get(b"DefaultRGB").is_some(); used.default_cmyk |= spaces.get(b"DefaultCMYK").is_some(); + for (k, entry) in spaces.iter() { + if !matches!(k.as_slice(), b"DefaultGray" | b"DefaultRGB" | b"DefaultCMYK") { + mark_cs_object(doc, entry, &mut used, 0); + } + } } } - if let Some(cs) = d.get(b"ColorSpace").map(|c| doc.resolve(c)) { - match &*cs { - Object::Name(n) if n == b"DeviceGray" => used.gray = true, - Object::Name(n) if n == b"DeviceRGB" => used.rgb = true, - Object::Name(n) if n == b"DeviceCMYK" => used.cmyk = true, - _ => {} - } + if let Some(cs) = d.get(b"ColorSpace") { + mark_cs_object(doc, cs, &mut used, 0); } // Content streams only: a font program or an ICC profile is data, whatever bytes it holds. // Parsing is the expensive part, so once content has used all three spaces no more is read. @@ -947,8 +982,20 @@ fn check_objects( let Some(d) = dict_of(o) else { continue }; check_one_object(doc, level, d, o, drawing.contains(r), page_of(*r), issue); } + for (i, p) in pdfcraft_model::pages(doc).into_iter().enumerate() { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + for entry in a { + if let Object::Dict(d) = entry { + check_one_object(doc, level, d, entry, false, Some(i), issue); + } + } + } + } } +/// Checks a single object against the PDF/A rules for `level`. fn check_one_object( doc: &Document, level: Level, @@ -961,13 +1008,14 @@ fn check_one_object( let ty = dict.name(b"Type"); let subtype = dict.name(b"Subtype"); check_actions(doc, dict, issue); - if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page, issue) { + let is_annot = matches!(object, Object::Dict(_)) && is_annotation(doc, dict, ty, subtype, page.is_some()); + if is_annot && check_annotation(doc, dict, subtype, page, issue) { // A forbidden annotation stops every later check for its object, as the scan did before // it was split into helpers: the rest of its own checks and the form field, font, stream // and embedded file below all came after that `continue`. return; } - if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { + if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && !is_annot { issue("6.5.2", "A form field has additional actions", None, None, true); } // A Type 0 font's descendant is checked through it. @@ -992,8 +1040,11 @@ fn check_actions(doc: &Document, dict: &Dict, issue: &mut impl FnMut(&'static st } } -fn is_annotation(dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>) -> bool { - ty == Some(b"Annot") || (subtype.is_some() && dict.get(b"Rect").is_some() && dict.get(b"P").is_some()) +/// Whether `dict` is an annotation dictionary (`/Type /Annot`, or a dictionary with the required +/// `/Subtype` and `/Rect` array entries that either sits in a page's `/Annots` list or carries `/P`). +fn is_annotation(doc: &Document, dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>, on_page: bool) -> bool { + ty == Some(b"Annot") + || (subtype.is_some() && dict.get(b"Rect").is_some_and(|r| doc.resolve(r).as_array().is_some()) && (on_page || dict.get(b"P").is_some())) } fn check_annotation( @@ -1044,6 +1095,7 @@ fn check_annotation_appearance( } } +/// Checks a stream's compression filters, external file keys, and image/PostScript subtype rules. fn check_stream( doc: &Document, stream: &pdfcraft_cos::Stream, @@ -1053,12 +1105,12 @@ fn check_stream( let filters: Vec> = match stream.dict.get(b"Filter").map(|filter| doc.resolve(filter)) { Some(filter) => match &*filter { Object::Name(name) => vec![name.clone()], - Object::Array(filters) => filters.iter().filter_map(|filter| filter.as_name().map(<[u8]>::to_vec)).collect(), + Object::Array(filters) => filters.iter().filter_map(|filter| doc.resolve(filter).as_name().map(<[u8]>::to_vec)).collect(), _ => Vec::new(), }, None => Vec::new(), }; - if filters.iter().any(|filter| filter == b"LZWDecode") { + if filters.iter().any(|filter| filter == b"LZWDecode" || filter == b"LZW") { issue("6.1.7.2", "A stream uses LZW compression", None, None, false); } if stream.dict.get(b"F").is_some() || stream.dict.get(b"FFilter").is_some() { @@ -1223,6 +1275,86 @@ fn strip_actions(doc: &Document, d: &mut Dict) -> bool { changed } +/// Collects every indirect annotation reference listed in any page's `/Annots` array. +fn page_annot_refs(doc: &Document) -> std::collections::HashSet { + let mut refs = std::collections::HashSet::new(); + for p in pdfcraft_model::pages(doc) { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + refs.extend(a.iter().filter_map(Object::as_ref)); + } + } + refs +} + +/// Removes annotation `r` from every page's `/Annots` list and replaces its indirect object with `Null`. +fn remove_annot_from_pages(doc: &mut Document, r: ObjRef) -> Result<(), pdfcraft_cos::CosError> { + for p in pdfcraft_model::pages(doc) { + let annots = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + if annots.iter().any(|a| a.as_ref() == Some(r)) { + let kept: Vec = annots.into_iter().filter(|a| a.as_ref() != Some(r)).collect(); + doc.update_dict(p.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?; + } + } + doc.set(r, Object::Null); + Ok(()) +} + +fn fix_annot_or_field(doc: &Document, nd: &mut Dict, is_annot: bool, sub: Option<&[u8]>, actions: &mut usize, flags: &mut usize) -> bool { + let mut changed = strip_actions(doc, nd); + *actions += changed as usize; + if is_annot && sub != Some(b"Popup") { + let f = nd.int(b"F").unwrap_or(0); + let want = (f | 4) & !(1 | 2 | 32); + if want != f { + nd.set(b"F".to_vec(), Object::Int(want)); + *flags += 1; + changed = true; + } + } + changed +} + +fn convert_direct_page_annots( + doc: &mut Document, + page: &pdfcraft_model::Page, + actions: &mut usize, + flags: &mut usize, + removed: &mut usize, +) -> Result<(), pdfcraft_cos::CosError> { + let Some(annots) = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { + return Ok(()); + }; + if !annots.iter().any(|a| a.as_dict().is_some()) { + return Ok(()); + } + let mut kept = Vec::with_capacity(annots.len()); + let mut changed = false; + for entry in annots { + let Object::Dict(mut nd) = entry else { + kept.push(entry); + continue; + }; + let ty = nd.name(b"Type").map(<[u8]>::to_vec); + let sub = nd.name(b"Subtype").map(<[u8]>::to_vec); + let is_annot = is_annotation(doc, &nd, ty.as_deref(), sub.as_deref(), true); + if is_annot && sub.as_deref().is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { + *removed += 1; + changed = true; + continue; + } + if is_annot || nd.get(b"FT").is_some() { + changed |= fix_annot_or_field(doc, &mut nd, is_annot, sub.as_deref(), actions, flags); + } + kept.push(Object::Dict(nd)); + } + if changed { + doc.update_dict(page.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?; + } + Ok(()) +} + /// Make `doc` conform to `level` as far as possible, then re-check it. pub fn convert(doc: &mut Document, level: Level) -> Result { let mut fixed = Vec::new(); @@ -1293,45 +1425,26 @@ pub fn convert(doc: &mut Document, level: Level) -> Result::to_vec); let sub = d.name(b"Subtype").map(<[u8]>::to_vec); - let is_annot = ty.as_deref() == Some(b"Annot"); + let is_annot = matches!(&*o, Object::Dict(_)) && is_annotation(doc, &d, ty.as_deref(), sub.as_deref(), page_annots.contains(&r)); if is_annot && sub.as_deref().is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { - // Take it off its page. - for p in pdfcraft_model::pages(doc) { - let annots = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); - if annots.iter().any(|a| a.as_ref() == Some(r)) { - let kept: Vec = annots.into_iter().filter(|a| a.as_ref() != Some(r)).collect(); - doc.update_dict(p.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?; - } - } - // The object stays in the file unless it is replaced: verify scans every object, so a - // forbidden annotation left behind would still be reported (clause 6.3.1). - doc.set(r, Object::Null); + remove_annot_from_pages(doc, r)?; removed += 1; continue; } if matches!(&*o, Object::Dict(_)) && (is_annot || d.get(b"FT").is_some()) { let mut nd = d.clone(); - let mut changed = strip_actions(doc, &mut nd); - actions += changed as usize; - if is_annot && sub.as_deref() != Some(b"Popup") { - let f = nd.int(b"F").unwrap_or(0); - let want = (f | 4) & !(1 | 2 | 32); - if want != f { - nd.set(b"F".to_vec(), Object::Int(want)); - flags += 1; - changed = true; - } - } - if changed { + if fix_annot_or_field(doc, &mut nd, is_annot, sub.as_deref(), &mut actions, &mut flags) { doc.set_existing(r, Object::Dict(nd))?; } continue; diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index e0107d9f21..fca88769e0 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -495,6 +495,89 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { } } +#[test] +fn typeless_annotations_indexed_cmyk_and_indirect_filter_elements_are_handled() { + // Annotations in /Annots that omit optional /Type /Annot and /P (while keeping required /Subtype + // and /Rect), whether indirect or direct dictionaries, are still verified and fixed by convert. + // A malformed /Annots dictionary without /Type /Annot and without /Rect is not treated as an + // annotation or destructively removed by convert. + let mut doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 8 0 R << /Subtype /Movie /Rect [60 10 70 30] >> << /Subtype /Sound >> << /Subtype /Sound /Rect null >> << /Subtype /Sound /Rect 9 0 R >>] >>", + "<< /Subtype /Text /Rect [10 10 30 30] /P 3 0 R /AP << /N 7 0 R >> >>", + "<< /Subtype /Sound /Rect [50 10 60 30] >>", + "<< /Subtype /Widget /FT /Tx /T (f) /Rect [70 10 90 30] /F 4 /AA << /E << /S /URI /URI (https://example.com) >> >> /AP << /N 7 0 R >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream", + "<< /Subtype /Movie >>", + "null", + ], + "", + ); + let issues = verify(&doc, Level::A2b); + let msgs = messages(&issues); + assert!(msgs.contains(&"A Text annotation isn't set to print, or is hidden"), "{msgs:#?}"); + assert!(msgs.contains(&"Sound annotations aren't allowed"), "{msgs:#?}"); + assert!(msgs.contains(&"Movie annotations aren't allowed"), "{msgs:#?}"); + assert!(msgs.contains(&"A Widget annotation has additional actions"), "{msgs:#?}"); + assert!(!msgs.contains(&"A form field has additional actions"), "{msgs:#?}"); + let r = convert(&mut doc, Level::A2b).unwrap(); + assert!(r.remaining.is_empty(), "{:#?}", r.remaining); + // The two /Rect-bearing forbidden annotations (5 0 R and the inline /Movie with /Rect) were + // removed, while the /Rect-less or null-/Rect dictionaries (8 0 R and the inline /Sound + // entries) without /Type /Annot are left untouched rather than destructively stripped. + assert_eq!(&*doc.get(ObjRef::new(5, 0)), &Object::Null); + assert!(doc.get(ObjRef::new(8, 0)).as_dict().is_some()); + let page_annots = doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.get(b"Annots")).and_then(Object::as_array).cloned().unwrap(); + assert_eq!(page_annots.len(), 6, "{page_annots:#?}"); + assert!(page_annots.iter().any(|entry| entry.as_ref() == Some(ObjRef::new(8, 0)))); + assert_eq!(page_annots.iter().filter(|entry| entry.as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Sound"))).count(), 3); + + // Indexed and Separation colour spaces over DeviceCMYK on image XObjects or /ColorSpace resources count as CMYK. + let indexed_img = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>", + "<< /Length 7 >>\nstream\n/Im0 Do\nendstream", + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace [/Indexed /DeviceCMYK 0 <00000000>] /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream", + ], + "", + ); + assert!(uses_cmyk(&indexed_img)); + + let res_cmyk = drawing("/CS0 cs 0 sc 0 0 10 10 re f", "/ColorSpace << /CS0 [/Indexed /DeviceCMYK 0 <00000000>] >>", &[]); + assert!(uses_cmyk(&res_cmyk)); + let pattern_cmyk = drawing("0 0 10 10 re f", "/ColorSpace << /CS0 [/Pattern /DeviceCMYK] >>", &[]); + assert!(uses_cmyk(&pattern_cmyk)); + let chained_cs = drawing("0 0 10 10 re f", "/ColorSpace << /CS0 5 0 R >>", &["6 0 R", "[7 0 R 8 0 R]", "/Pattern", "/DeviceCMYK"]); + assert!(uses_cmyk(&chained_cs)); + + // An indirect filter name inside a /Filter array or the /LZW abbreviation is still checked for LZWDecode. + let indirect_lzw = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + "<< /Filter [5 0 R] /Length 0 >>\nstream\n\nendstream", + "/LZWDecode", + ], + "", + ); + assert!(messages(&verify(&indirect_lzw, Level::A2b)).contains(&"A stream uses LZW compression")); + let abbrev_lzw = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + "<< /Filter /LZW /Length 0 >>\nstream\n\nendstream", + ], + "", + ); + assert!(messages(&verify(&abbrev_lzw, Level::A2b)).contains(&"A stream uses LZW compression")); +} + /// PDF/A-2 6.1.13 holds a file to the implementation limits of ISO 32000-1 Annex C (Table C.1): /// an integer past 32 bits, a string over 32767 bytes or a name over 127 bytes (decoded: a name /// written with `#xx` escapes counts its bytes once read), in an object or in content; and a page diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 6ffa60b813..7a0506ca8b 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] craft-print = { workspace = true } +log = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-content = { workspace = true } pdfcraft-model = { workspace = true } @@ -17,3 +18,5 @@ thiserror = { workspace = true } [dev-dependencies] pdfcraft-render = { workspace = true } +# Reads the renamed programs back with a parser that isn't ours. +skrifa = "0.44" diff --git a/crates/print/README.md b/crates/print/README.md index 9866f788a3..53155071f2 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -26,10 +26,14 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default() Headless: `doc_print` with `layout: "multiple"`, `order: "cut-stack"` and `per_sheet: 4` (or any supported grid size), plus `path` or `printer`. - **Booklet**: saddle-stitch imposition padded to a multiple of 4; both sides, front or back - only; left or right binding. + only; left or right binding; creep compensation, which moves each sheet's pages toward the + fold by its place from the outside times the creep (points per sheet, up to `MAX_CREEP`), + clipped at the fold. Headless: `doc_print` with `layout: "booklet"` and `creep`. - **Poster**: tile scale, overlap shared by neighbouring tiles, cut marks. - **Comments & forms**: document, + markups, + stamps, or form fields only; annotations print only with their Print flag, drawn from their appearance streams (Algorithm 8.1). + Existing Link appearances are document content in the first three modes; Link appearances + are not synthesized when absent, and form fields only excludes them. - **Properties… on CUPS** reads the queue's PPD and its current defaults (`lpoptions -l`) off the GUI thread, and gives `lpoptions` ten seconds: a CUPS that has stopped answering leaves the PPD's own defaults in force instead of holding the dialog (#1818; `spool::output_within` @@ -70,6 +74,28 @@ orientation, zero added margins and hardware-edge clipping. Long-edge and short- use the corresponding Windows binding edges. Random, exclusively created Windows job files are removed on success and failure. Other targets offer the print-ready PDF instead. +On Windows, **Printer default** reads the selected driver's current `DEVMODEW` duplex +setting immediately before submission and carries that binding explicitly into the job +(#5130). This also applies to `doc_print` with `duplex: "printer"`; its omitted-argument +default remains `off`. Explicit Off/Long Edge/Short Edge choices, booklet binding and +single-sided cut-and-stack bypass this lookup. Drivers without `DM_DUPLEX` retain the +unspecified setting. Two-sided defaults also stay unspecified when the driver does not +confirm automatic two-sided support, preserving manual duplex preferences (#5772). +A failed lookup, settings read or invalid declared duplex value is +logged and falls back to leaving duplex unspecified, so the driver can still choose its default. +If the queue was resolved before the settings failure, the job remains pinned to that queue. CUPS still leaves the choice to the queue. The Windows backend +selects `JobDuplexAllDocumentsContiguously`, falling back to `DocumentDuplex` when +the driver exposes only document-level duplex, and validates the retained binding before submission +([craft-libs #411](https://github.com/storytold/craft-libs/pull/411)). + +Before a job reaches the Windows spooler, every embedded TrueType or OpenType program is +renamed `PdfCraftPrint` in its `name` table and in the PDF (`/BaseFont`, `/FontName`; a +subset keeps its tag). Windows otherwise draws a subset's renumbered glyphs with the installed +font of the same name, so ReportLab's Arial subsets printed as shifted letters (#3132). Glyphs, +encodings and `/ToUnicode` are unchanged, and CUPS and Save as PDF get the PDF as it was. A +program PdfCraft can't read and rewrite keeps its old name in the PDF too, so a viewer that +can't load it either still picks its substitute by that name. + The shared dependency requires Rust 1.95. Transport and native Windows regression tests live in craft-libs; local tests retain composition, facade, engine, automation and UI coverage. The desktop UI discovers printers and submits jobs on workers, so neither operation blocks diff --git a/crates/print/src/font_names.rs b/crates/print/src/font_names.rs new file mode 100644 index 0000000000..fc3b0e3a18 --- /dev/null +++ b/crates/print/src/font_names.rs @@ -0,0 +1,388 @@ +//! Embedded TrueType and OpenType programs renamed before a print-ready PDF goes to the Windows +//! spooler, so no installed font answers to their names (#3132). +//! +//! A subset program numbers its glyphs anew: ReportLab's Arial subsets draw a space with glyph 11 +//! (Arial Bold: glyph 8), where the installed Arial's space is glyph 3. Windows' print path +//! (Windows.Data.Pdf drawing into Direct2D's print control, through craft-print) keeps the +//! subset's glyph numbers but draws them with the installed font of the same name, so every letter +//! prints as the installed glyph with that number: `Revisión de clientes,` in the bold subset comes +//! out as `Wj{nxn s%ij%hqnjsyjx1`, its `z` as `Ä`. Both Brother queues and the Adobe PDF printer +//! show it; PdfCraft's renderer, CUPS and Chrome on Windows draw the embedded program and are right. +//! +//! [`distinct`] renames each program to `PdfCraftPrint`: the identity records of its `name` +//! table, `/FontName` of its descriptors and `/BaseFont` of the fonts drawn with it (a subset keeps +//! its tag, `AAAAAA+PdfCraftPrint1`). Glyphs, glyph numbers, cmaps, encodings, widths and +//! `/ToUnicode` are not touched, so pages draw and extract as before. A program that can't be +//! read and rewritten keeps its old name everywhere. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + +use crate::PrintError; + +/// Whether [`crate::spool`] renames the programs before spooling: on Windows only. +pub const RENAME_FOR_SPOOLER: bool = cfg!(windows); + +/// Decoded size past which a program is left as it is (a whole CJK font is about 20 MB). +const MAX_PROGRAM: usize = 64 << 20; + +/// Decoded bytes the whole renaming may read, across every program. A document's fonts rarely +/// come to 100 MB; a file of many distinct compressed bombs is read only this far. +const MAX_DECODED_IN_ALL: usize = 512 << 20; + +/// More tables than any font has; a larger count is a damaged directory. +const MAX_TABLES: usize = 256; + +/// The `name` records that say which font this is: family, unique ID, full name, PostScript name, +/// typographic family, Mac compatible full name, PostScript CID name, WWS family and the +/// variations PostScript name prefix. +const IDENTITY: [u16; 9] = [1, 3, 4, 6, 16, 18, 20, 21, 25]; + +/// The font subtypes whose descriptor can hold a TrueType or OpenType program. +const DRAWN_WITH_A_PROGRAM: [&[u8]; 5] = [b"TrueType", b"Type1", b"MMType1", b"CIDFontType0", b"CIDFontType2"]; + +/// An embedded program and what it will be called. +struct Program { + name: String, + /// A `/FontFile2` program, whose `/Length1` is the decoded length. + true_type: bool, +} + +/// The fonts drawn with embedded TrueType or OpenType programs, found by scanning every object. +#[derive(Default)] +struct Found { + /// Each program, numbered in object order. + programs: BTreeMap, + /// Simple fonts and CIDFonts, with their program. + fonts: BTreeMap, + /// Indirect font descriptors, with their program. + descriptors: BTreeMap, + /// Type 0 fonts whose CIDFont is an object of its own, with that CIDFont. + type0: BTreeMap, + /// Type 0 fonts whose CIDFont is written inline, with the array object holding it when + /// `/DescendantFonts` names one, and the CIDFont's program. + inline: BTreeMap, ObjRef)>, +} + +impl Found { + /// Note the program `font` draws with, and its descriptor when that is an object of its own; + /// the program, if it has one to rename. + fn add(&mut self, doc: &Document, font: &Dict) -> Option { + if !font.name(b"Subtype").is_some_and(|s| DRAWN_WITH_A_PROGRAM.contains(&s)) { + return None; + } + let descriptor = font.get(b"FontDescriptor")?; + let (program, true_type) = doc.dict(descriptor).and_then(|d| program(doc, &d))?; + let number = self.programs.len() + 1; + self.programs.entry(program).or_insert_with(|| Program { name: format!("PdfCraftPrint{number}"), true_type }); + if let Object::Ref(descriptor) = descriptor { + self.descriptors.insert(*descriptor, program); + } + Some(program) + } +} + +/// `pdf` with every embedded TrueType or OpenType program renamed; `None` when it has none that +/// can be. +pub fn distinct(pdf: &[u8]) -> Result>, PrintError> { + distinct_within(pdf, &mut DecodeBudget::new(MAX_DECODED_IN_ALL, 0)) +} + +/// [`distinct`], decoding the programs against `budget`: once it is spent, the programs not yet +/// read keep their names, as one that can't be read does. +pub(crate) fn distinct_within(pdf: &[u8], budget: &mut DecodeBudget) -> Result>, PrintError> { + let mut doc = Document::open(Arc::new(pdf.to_vec()))?; + let mut found = find(&doc); + // A program that can't be rewritten keeps its names in the PDF too: a viewer that can't load + // it either then still picks its substitute by the original name. The lookups below skip it. + found.programs.retain(|program, about| rename_program(&mut doc, *program, about, budget)); + if found.programs.is_empty() { + return Ok(None); + } + for (descriptor, program) in &found.descriptors { + let Some(about) = found.programs.get(program) else { continue }; + doc.update_dict(*descriptor, |d| rename(d, b"FontName", &about.name))?; + } + for (font, program) in &found.fonts { + let Some(about) = found.programs.get(program) else { continue }; + doc.update_dict(*font, |d| rename_font(d, &about.name))?; + } + // A Type 0 font is named after its CIDFont; renaming only the CIDFont would leave the old + // name on the font the page uses. + for (font, cid) in &found.type0 { + let Some(about) = found.fonts.get(cid).and_then(|p| found.programs.get(p)) else { continue }; + doc.update_dict(*font, |d| rename(d, b"BaseFont", &about.name))?; + } + for (font, (array, program)) in &found.inline { + let Some(about) = found.programs.get(program) else { continue }; + doc.update_dict(*font, |d| { + rename(d, b"BaseFont", &about.name); + if let Some(Object::Array(descendants)) = d.get_mut(b"DescendantFonts") { + rename_first(descendants, &about.name); + } + })?; + if let Some(array) = array { + let Object::Array(mut descendants) = doc.get(*array).as_ref().clone() else { continue }; + rename_first(&mut descendants, &about.name); + doc.set(*array, Object::Array(descendants)); + } + } + Ok(Some(write_full(&doc, &SaveOptions::default())?)) +} + +fn find(doc: &Document) -> Found { + let mut found = Found::default(); + for (reference, object) in doc.scan_objects() { + let Object::Dict(font) = object.as_ref() else { continue }; + if font.name(b"Subtype") == Some(b"Type0") { + find_descendant(doc, reference, font, &mut found); + } else if let Some(program) = found.add(doc, font) { + found.fonts.insert(reference, program); + } + } + found +} + +/// Note the CIDFont a Type 0 font is named after: an object of its own, renamed when the scan +/// reaches it, or written inline, which no scan reaches, so its program is noted here. +fn find_descendant(doc: &Document, font: ObjRef, d: &Dict, found: &mut Found) { + let Some(descendants) = d.get(b"DescendantFonts") else { return }; + let array = match descendants { + Object::Ref(array) => Some(*array), + _ => None, + }; + let descendants = doc.resolve(descendants); + match descendants.as_array().and_then(|a| a.first()) { + Some(Object::Ref(cid)) => { + found.type0.insert(font, *cid); + } + Some(Object::Dict(cid)) => { + if let Some(program) = found.add(doc, cid) { + found.inline.insert(font, (array, program)); + } + } + _ => {} + } +} + +/// Rename a simple font or CIDFont, and its descriptor when that is written inline. +fn rename_font(d: &mut Dict, name: &str) { + rename(d, b"BaseFont", name); + if let Some(Object::Dict(descriptor)) = d.get_mut(b"FontDescriptor") { + rename(descriptor, b"FontName", name); + } +} + +/// Rename the CIDFont written inline at the head of `/DescendantFonts`. +fn rename_first(descendants: &mut [Object], name: &str) { + if let Some(Object::Dict(cid)) = descendants.first_mut() { + rename_font(cid, name); + } +} + +/// The descriptor's TrueType program (`/FontFile2`), else its OpenType one (`/FontFile3` with +/// `/Subtype /OpenType`), with whether it is TrueType. +fn program(doc: &Document, descriptor: &Dict) -> Option<(ObjRef, bool)> { + if let Some(Object::Ref(program)) = descriptor.get(b"FontFile2") { + return Some((*program, true)); + } + let Some(Object::Ref(program)) = descriptor.get(b"FontFile3") else { return None }; + let open_type = matches!(doc.get(*program).as_ref(), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"OpenType")); + open_type.then_some((*program, false)) +} + +/// Set `key` to `name`, keeping the subset tag the old name had. +fn rename(d: &mut Dict, key: &[u8], name: &str) { + let mut renamed = d.name(key).and_then(subset_tag).map(<[u8]>::to_vec).unwrap_or_default(); + renamed.extend_from_slice(name.as_bytes()); + d.set(key.to_vec(), Object::Name(renamed)); +} + +/// `ABCDEF+` at the start of a subset font's name (ISO 32000-2 §9.9.2). +fn subset_tag(name: &[u8]) -> Option<&[u8]> { + let tag = name.get(..7)?; + let (letters, plus) = tag.split_at(6); + (letters.iter().all(u8::is_ascii_uppercase) && plus == b"+").then_some(tag) +} + +/// Rewrite the program with its `name` table renamed; whether it was. A program that can't be +/// decoded within `budget` or isn't an sfnt with a readable `name` table stays as it is. +fn rename_program(doc: &mut Document, program: ObjRef, about: &Program, budget: &mut DecodeBudget) -> bool { + let object = doc.get(program); + let Object::Stream(stream) = object.as_ref() else { return false }; + let Ok(bytes) = budget.decode_within(stream, MAX_PROGRAM) else { return false }; + let Some(renamed) = renamed_sfnt(&bytes, &about.name) else { return false }; + let mut dict = stream.dict.clone(); + dict.remove(b"DL"); + if about.true_type || dict.get(b"Length1").is_some() { + dict.set(b"Length1".to_vec(), Object::Int(i64::try_from(renamed.len()).unwrap_or(i64::MAX))); + } + doc.set(program, Object::Stream(Stream::flate(dict, &renamed))); + true +} + +fn u16_at(bytes: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes(bytes.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +fn u32_at(bytes: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes(bytes.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} + +fn checksum(data: &[u8]) -> u32 { + data.chunks(4).fold(0_u32, |sum, c| { + let mut word = [0; 4]; + for (a, b) in word.iter_mut().zip(c) { + *a = *b; + } + sum.wrapping_add(u32::from_be_bytes(word)) + }) +} + +/// `program` with the identity records of its `name` table saying `name`. The new table goes +/// after the others, as `pdfcraft_fonts` writes its editor cmap, and the directory entry and +/// `head`'s checksum adjustment are brought up to date. `None` when `program` is not a single +/// sfnt font with a `name` table that can be read. +pub(crate) fn renamed_sfnt(program: &[u8], name: &str) -> Option> { + let version = program.get(..4)?; + if version != [0, 1, 0, 0] && version != b"true" && version != b"OTTO" { + return None; + } + let count = usize::from(u16_at(program, 4)?); + if count > MAX_TABLES { + return None; + } + let (mut entry, mut head) = (None, None); + for at in (0..count).map(|i| 12 + i * 16) { + match program.get(at..at + 4)? { + b"name" => entry = Some(at), + b"head" => head = Some(usize::try_from(u32_at(program, at + 8)?).ok()?), + _ => {} + } + } + let entry = entry?; + let offset = usize::try_from(u32_at(program, entry + 8)?).ok()?; + let length = usize::try_from(u32_at(program, entry + 12)?).ok()?; + let table = renamed_table(program.get(offset..offset.checked_add(length)?)?, name)?; + let mut out = program.to_vec(); + while !out.len().is_multiple_of(4) { + out.push(0); + } + let at = u32::try_from(out.len()).ok()?; + out.get_mut(entry + 4..entry + 8)?.copy_from_slice(&checksum(&table).to_be_bytes()); + out.get_mut(entry + 8..entry + 12)?.copy_from_slice(&at.to_be_bytes()); + out.get_mut(entry + 12..entry + 16)?.copy_from_slice(&u32::try_from(table.len()).ok()?.to_be_bytes()); + out.extend_from_slice(&table); + if let Some(head) = head { + let adjustment = head.checked_add(8)?..head.checked_add(12)?; + out.get_mut(adjustment.clone())?.fill(0); + let sum = checksum(&out); + out.get_mut(adjustment)?.copy_from_slice(&0xb1b0afba_u32.wrapping_sub(sum).to_be_bytes()); + } + Some(out) +} + +/// Where a record's string comes from: the old table's storage (offset and length), or the new +/// name in UTF-16BE or in single bytes. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Text { + Old(u16, u16), + Wide, + Narrow, +} + +/// A `name` table (format 0 or 1) with its identity records saying `name`. The records stay in +/// their order, which the format requires to be sorted. An identity record in an encoding that +/// can't spell `name`, or any record whose string lies outside the table, is left out; a broken +/// language tag fails the table, because records refer to the tags by position. +/// +/// Nothing is copied until the whole table has been read, and then each distinct string once: +/// records that shared a string still share it, and the strings stay within what 16-bit offsets +/// reach, so a small table can't make the copy large (#4098). +fn renamed_table(table: &[u8], name: &str) -> Option> { + let format = u16_at(table, 0)?; + if format > 1 { + return None; + } + let count = usize::from(u16_at(table, 2)?); + let storage = usize::from(u16_at(table, 4)?); + let old = |offset: u16, length: u16| table.get(storage.checked_add(usize::from(offset))?..)?.get(..usize::from(length)); + let tags_at = 6 + count * 12; + let tag_count = if format == 1 { usize::from(u16_at(table, tags_at)?) } else { 0 }; + // The records and tags come before the strings, whose offset is 16 bits: a table that claims + // more of them overlaps its own strings and is refused. + u16::try_from(tags_at + if format == 1 { 2 + tag_count * 4 } else { 0 }).ok()?; + let mut records: Vec<([u16; 4], Text)> = Vec::with_capacity(count); + for at in (0..count).map(|i| 6 + i * 12) { + let field = |i: usize| u16_at(table, at + 2 * i); + let key = [field(0)?, field(1)?, field(2)?, field(3)?]; + let [platform, encoding, _, id] = key; + let text = if IDENTITY.contains(&id) { + spelling(platform, encoding) + } else { + let (length, offset) = (field(4)?, field(5)?); + old(offset, length).map(|_| Text::Old(offset, length)) + }; + if let Some(text) = text { + records.push((key, text)); + } + } + let mut tags = Vec::with_capacity(tag_count); + for at in (0..tag_count).map(|i| tags_at + 2 + 4 * i) { + let (length, offset) = (u16_at(table, at)?, u16_at(table, at + 2)?); + old(offset, length)?; + tags.push(Text::Old(offset, length)); + } + let wide: Vec = name.encode_utf16().flat_map(u16::to_be_bytes).collect(); + let mut placed: BTreeMap = BTreeMap::new(); + let mut strings = Vec::new(); + // A string's length and offset in the new storage, storing it the first time it is asked for. + let mut place = |text: Text| -> Option<[u16; 2]> { + if let Some(at) = placed.get(&text) { + return Some(*at); + } + let bytes = match text { + Text::Old(offset, length) => old(offset, length)?, + Text::Wide => &wide, + Text::Narrow => name.as_bytes(), + }; + let at = [u16::try_from(bytes.len()).ok()?, u16::try_from(strings.len()).ok()?]; + strings.extend_from_slice(bytes); + placed.insert(text, at); + Some(at) + }; + let header = 6 + records.len() * 12 + if format == 1 { 2 + tags.len() * 4 } else { 0 }; + let mut out = Vec::with_capacity(header); + for v in [format, u16::try_from(records.len()).ok()?, u16::try_from(header).ok()?] { + out.extend_from_slice(&v.to_be_bytes()); + } + for (key, text) in &records { + for v in key.iter().chain(&place(*text)?) { + out.extend_from_slice(&v.to_be_bytes()); + } + } + if format == 1 { + out.extend_from_slice(&u16::try_from(tags.len()).ok()?.to_be_bytes()); + for tag in &tags { + for v in place(*tag)? { + out.extend_from_slice(&v.to_be_bytes()); + } + } + } + out.extend_from_slice(&strings); + Some(out) +} + +/// How `name` (ASCII) is written in a `name` record of this platform and encoding: UTF-16BE for +/// Unicode and the Windows Unicode and symbol encodings, single bytes for Macintosh (every Mac +/// script encoding starts with ASCII); `None` for the legacy Windows CJK encodings and the ISO and +/// custom platforms. +fn spelling(platform: u16, encoding: u16) -> Option { + match (platform, encoding) { + (0, _) | (3, 0 | 1 | 10) => Some(Text::Wide), + (1, _) => Some(Text::Narrow), + _ => None, + } +} diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index 19a75af32c..d5ad4e2648 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -100,7 +100,9 @@ pub(crate) fn prints(doc: &Document, d: &Dict, content: Content) -> bool { let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b""); match subtype { b"Widget" => true, - b"Link" | b"Popup" => false, + // Existing Link appearances are part of the document, not markup or fields. + b"Link" => content != Content::FormFieldsOnly, + b"Popup" => false, b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, // ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table // 171) is not printed even with its Print flag set. diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index fca266b870..84cca070da 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -20,6 +20,7 @@ use pdfcraft_content::Matrix; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +mod font_names; pub mod range; pub mod spool; #[cfg(test)] @@ -43,7 +44,7 @@ pub use settings::{ #[cfg(test)] use sheets::text_direction; pub use sheets::{ - LABEL_MARGIN, MARGIN, Placement, Sheet, add_cups_orientation, cups_orientation, layout, matching_paper, text_reads_up, texts_read_up, + LABEL_MARGIN, MARGIN, MAX_CREEP, Placement, Sheet, add_cups_orientation, cups_orientation, layout, matching_paper, text_reads_up, texts_read_up, }; /// The names of the Print dialog's choices, shared by the `doc_print` tool and the settings diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index 29540c8c19..bdcb3e3540 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -75,9 +75,12 @@ pub enum Layout { /// portrait sheet that turns to landscape when that suits it. custom: bool, }, + /// `creep` (points, per sheet) moves sheet k's pages toward the fold by `k × creep`, sheet 0 + /// being the outermost, so the margins match once the nested sheets are folded and trimmed. Booklet { subset: BookletSubset, binding: Binding, + creep: f64, }, /// Tile scale (percent), overlap (points), cut marks. Poster { diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index c9e1eb9681..24b1b5d5de 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -168,6 +168,25 @@ fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: b Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } } +/// The most creep per sheet, in points. Paper adds a fraction of a point per sheet; more is a mistake. +pub const MAX_CREEP: f64 = 10.0; + +/// `placed` moved `shift` points across the sheet (positive is rightward) and clipped at the fold +/// at `fold`, so a page moved toward it never prints on the facing page. +fn creep_toward_fold(mut placed: Placement, shift: f64, fold: f64, s: f64) -> Placement { + if shift == 0.0 || s <= 0.0 { + return placed; + } + placed.matrix.0[4] += shift; + let at_fold = (fold - placed.matrix.0[4]) / s; + if shift > 0.0 { + placed.clip[2] = placed.clip[2].min(at_fold); + } else { + placed.clip[0] = placed.clip[0].max(at_fold); + } + placed +} + fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } } @@ -321,13 +340,19 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P sheets.push(sheet); } } - Layout::Booklet { subset, binding } => { + Layout::Booklet { subset, binding, creep } => { // Pad to a multiple of 4 with blank pages (None). let n4 = pages.len().div_ceil(4) * 4; let at = |i: usize| pages.get(i).copied(); let size = oriented(paper, Orientation::Landscape, true); let half = (size.0 - 2.0 * MARGIN) / 2.0; let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; + // The innermost sheet moves furthest; it must stay within half its cell. `pages` is not + // empty (checked above), so there is at least one sheet. + let innermost = creep * (n4 / 4 - 1) as f64; + if !((0.0..=MAX_CREEP).contains(&creep) && innermost < half / 2.0) { + return Err(PrintError::Invalid("invalid booklet creep".into())); + } for k in 0..n4 / 4 { let front = (at(n4 - 1 - 2 * k), at(2 * k)); let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); @@ -341,11 +366,12 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P for (l, r) in sides { let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; let mut sheet = Sheet { size, ..Sheet::default() }; - for (p, cell) in [(l, cells[0]), (r, cells[1])] { + for (p, cell, toward) in [(l, cells[0], 1.0), (r, cells[1], -1.0)] { if let Some(p) = p { let d = sizes[p]; let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); - sheet.placed.push(place_in(p, d, cell, s, false)); + let placed = place_in(p, d, cell, s, false); + sheet.placed.push(creep_toward_fold(placed, toward * creep * k as f64, MARGIN + half, s)); } } sheets.push(sheet); diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index fd61301209..ddeba8354a 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -6,6 +6,9 @@ pub use craft_print::{ parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printers_parsed, }; +#[cfg(any(windows, test))] +mod windows_duplex; + /// Windows: the in-box DLLs printing draws and describes jobs with. The executables delay-load /// them (`apps/pdfcraft{,-cli}/build.rs`, and `build.rs` for this crate's tests: the same list) /// so a Windows installation without them still starts (#2630); a call into a missing @@ -24,8 +27,9 @@ thread_local! { /// Tests: the folder the print DLLs are looked for in, instead of the system's, so the /// public entry points can be checked against a Windows that lacks them. pub(crate) static SYSTEM32_FOR_TEST: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; - /// Tests: how many times [`printers`] asked the system's printer backend, so a test can tell - /// the guard answered without relying on the machine having no printers. + /// Tests: how many times [`printers`], [`printer_queues`] or [`two_sided`] asked the system's + /// printer backend, so a test can tell the guard answered without relying on the machine + /// having no printers. pub(crate) static PRINTER_BACKEND_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -59,6 +63,29 @@ pub fn printers() -> Vec { craft_print::printers() } +/// The system's print queues without asking any driver, so a Print dialog's list never waits on +/// a printer (#2192); none on a Windows installation without the print DLLs, as [`printers`]. +pub fn printer_queues() -> Vec { + if missing_print_dll().is_some() { + return Vec::new(); + } + #[cfg(all(windows, test))] + PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); + craft_print::printer_queues() +} + +/// Whether `printer`'s driver takes two-sided settings, read for that one printer; it can wait +/// on the device, so call it off the UI thread. Unknown on a Windows installation without the +/// print DLLs: reading it goes through them. +pub fn two_sided(printer: &str) -> Option { + if missing_print_dll().is_some() { + return None; + } + #[cfg(all(windows, test))] + PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); + craft_print::two_sided(printer) +} + /// The driver's job options for a printer (CUPS: its PPD, with the queue's current defaults); /// empty when it has none or on other platforms. Reads files and runs `lpoptions`, so call it /// when the user asks, not every frame. @@ -363,7 +390,12 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result { if let Some(dll) = missing_print_dll() { return Err(unavailable(dll)); } - craft_print::submit(pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string())) + #[cfg(windows)] + let resolved = windows_duplex::job(job); + #[cfg(windows)] + let job = resolved.as_ref(); + let pdf = spooled_pdf(pdf, crate::font_names::RENAME_FOR_SPOOLER); + craft_print::submit(&pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string())) } /// [`submit`], told `(sent, sheets)` as each sheet reaches the spooler (Windows; CUPS takes the @@ -372,7 +404,26 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz if let Some(dll) = missing_print_dll() { return Err(unavailable(dll)); } - craft_print::submit_with_progress(pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string())) + #[cfg(windows)] + let resolved = windows_duplex::job(job); + #[cfg(windows)] + let job = resolved.as_ref(); + let pdf = spooled_pdf(pdf, crate::font_names::RENAME_FOR_SPOOLER); + craft_print::submit_with_progress(&pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string())) +} + +/// The PDF as the system spooler takes it: with `rename` (Windows), its embedded TrueType and +/// OpenType programs renamed, so Windows can't draw a subset's glyph numbers with an installed +/// font of the same name (#3132, [`crate::font_names`]). A PDF that can't be read back goes as it +/// is: the renaming only protects the text from Windows, and the job shouldn't fail over it. +pub(crate) fn spooled_pdf(pdf: &[u8], rename: bool) -> std::borrow::Cow<'_, [u8]> { + if !rename { + return std::borrow::Cow::Borrowed(pdf); + } + match crate::font_names::distinct(pdf) { + Ok(Some(renamed)) => std::borrow::Cow::Owned(renamed), + Ok(None) | Err(_) => std::borrow::Cow::Borrowed(pdf), + } } pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> { diff --git a/crates/print/src/spool/windows_duplex.rs b/crates/print/src/spool/windows_duplex.rs new file mode 100644 index 0000000000..eca09cf9a1 --- /dev/null +++ b/crates/print/src/spool/windows_duplex.rs @@ -0,0 +1,214 @@ +//! Resolve Printer default from the Windows driver's current DEVMODE. Omitting duplex +//! from a PrintTicket leaves its interpretation to the XPS driver (#5130). + +use std::borrow::Cow; + +use super::{Duplex, Job}; +use crate::PrintError; + +/// Preserve an explicit duplex choice by borrowing `job` without calling `read`. +/// Otherwise call `read` once with the selected printer (or `None` for the system default), +/// returning an owned copy with the resolved printer and duplex value. Log lookup failures +/// and leave duplex unspecified so the spooler can use its previous default behavior; +/// the original job remains unchanged even when the driver has no duplex setting. +fn resolve<'a>(job: &'a Job, read: impl FnOnce(Option<&str>) -> Result<(String, Option), PrintError>) -> Cow<'a, Job> { + if job.duplex.is_some() { + return Cow::Borrowed(job); + } + let (printer, duplex) = match read(job.printer.as_deref()) { + Ok(settings) => settings, + Err(error) => { + driver_default(Err(error)); + return Cow::Borrowed(job); + } + }; + let mut resolved = job.clone(); + resolved.printer = Some(printer); + resolved.duplex = duplex; + Cow::Owned(resolved) +} + +/// A driver default is best-effort: record the fallback and let the driver choose. +fn driver_default(result: Result, PrintError>) -> Option { + match result { + Ok(value) => value, + Err(error) => { + log::warn!("could not resolve the printer's two-sided default; leaving duplex unspecified: {error}"); + None + } + } +} + +/// Preserve manual two-sided defaults when PrintCapabilities say the driver cannot take +/// explicit two-sided settings (#5772). Unknown capabilities also keep the driver's default: +/// turning a best-effort preference into a strict request could otherwise refuse a working job. +fn supported_default(value: Option, support: impl FnOnce() -> Option) -> Option { + if matches!(value, Some(Duplex::LongEdge | Duplex::ShortEdge)) && support() != Some(true) { None } else { value } +} + +/// DEVMODEW's public dmFields (offset 72) marks dmDuplex (offset 94) as valid with +/// DM_DUPLEX. Values are DMDUP_SIMPLEX, DMDUP_VERTICAL and DMDUP_HORIZONTAL, respectively. +/// Return `None` when DM_DUPLEX is absent, or a spool error for a malformed field or an +/// unrecognized declared value. Layout: . +fn duplex(settings: &craft_print::rgb::DevMode) -> Result, PrintError> { + let bytes = settings.as_bytes(); + let invalid = || PrintError::Spool("the printer driver returned an invalid two-sided default; check its printing preferences".into()); + let fields = bytes.get(72..76).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes).ok_or_else(invalid)?; + if fields & 0x1000 == 0 { + return Ok(None); + } + match bytes.get(94..96).and_then(|b| <[u8; 2]>::try_from(b).ok()).map(i16::from_le_bytes) { + Some(1) => Ok(Some(Duplex::Off)), + Some(2) => Ok(Some(Duplex::LongEdge)), + Some(3) => Ok(Some(Duplex::ShortEdge)), + _ => Err(invalid()), + } +} + +#[cfg(windows)] +/// Resolve an unspecified duplex setting from the selected Windows printer immediately +/// before submission. An unnamed printer is resolved to the system default and pinned to +/// the returned job. Explicit choices bypass the driver lookup. Lookup failures are logged +/// and leave duplex unspecified; a settings failure retains the resolved queue. +pub(super) fn job(job: &Job) -> Cow<'_, Job> { + resolve(job, |selected| { + let failed = |e| PrintError::Spool(format!("could not read the printer's two-sided default: {e}")); + let printer = match selected { + Some(name) => name.to_owned(), + None => craft_print::rgb::default_printer() + .map_err(failed)? + .ok_or_else(|| PrintError::Spool("there is no default printer; choose a printer".into()))?, + }; + let value = craft_print::rgb::default_settings(&printer).map_err(failed).and_then(|settings| duplex(&settings)); + // Only this printer's driver is asked (craft-print `two_sided`), not every queue's, as + // `printers()` would; a networked printer in power save could hold the job (#2192). + let value = supported_default(driver_default(value), || craft_print::two_sided(&printer)); + Ok((printer, value)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(fields: u32, value: i16) -> craft_print::rgb::DevMode { + let mut bytes = vec![0; 220]; + bytes[68..70].copy_from_slice(&220u16.to_le_bytes()); + bytes[72..76].copy_from_slice(&fields.to_le_bytes()); + bytes[94..96].copy_from_slice(&value.to_le_bytes()); + craft_print::rgb::DevMode::from_bytes(bytes).unwrap() + } + + #[test] + fn printer_default_reaches_the_windows_job_for_each_binding() { + for (value, expected) in [(1, Duplex::Off), (2, Duplex::LongEdge), (3, Duplex::ShortEdge)] { + let original = Job { + printer: Some("Office".into()), + copies: 3, + collate: false, + grayscale: true, + title: "report".into(), + options: vec![("media".into(), "A4".into())], + ..Job::default() + }; + let resolved = resolve(&original, |selected| { + assert_eq!(selected, Some("Office")); + Ok(("Office".into(), duplex(&settings(0x1000, value))?)) + }); + assert_eq!(resolved.duplex, Some(expected)); + assert_eq!(resolved.printer.as_deref(), Some("Office")); + assert_eq!(resolved.copies, 3); + assert_eq!(resolved.collate, original.collate); + assert_eq!(resolved.grayscale, original.grayscale); + assert_eq!(resolved.scaling, original.scaling); + assert_eq!(resolved.options, original.options); + assert_eq!(resolved.title, "report"); + assert_eq!(original.duplex, None, "Printer default remains selected for the next job"); + } + } + + #[test] + fn explicit_choices_do_not_read_or_change_the_printer_default() { + for choice in [Duplex::Off, Duplex::LongEdge, Duplex::ShortEdge] { + let original = Job { duplex: Some(choice), ..Job::default() }; + let resolved = resolve(&original, |_| panic!("explicit choice must skip driver settings")); + assert!(matches!(resolved, Cow::Borrowed(_))); + assert_eq!(resolved.duplex, Some(choice)); + } + } + + #[test] + fn system_default_queue_is_resolved_once_and_pinned_to_the_job() { + let original = Job::default(); + let resolved = resolve(&original, |selected| { + assert_eq!(selected, None); + Ok(("System default".into(), Some(Duplex::ShortEdge))) + }); + assert_eq!(resolved.printer.as_deref(), Some("System default")); + assert_eq!(resolved.duplex, Some(Duplex::ShortEdge)); + } + + #[test] + fn missing_duplex_field_is_left_to_the_driver_and_invalid_values_fail() { + assert_eq!(duplex(&settings(0, 99)).unwrap(), None); + for value in [0, -1, 4, i16::MAX] { + assert!(duplex(&settings(0x1000, value)).is_err()); + } + let original = Job::default(); + let resolved = resolve(&original, |_| Ok(("Simplex".into(), None))); + assert_eq!(resolved.duplex, None); + } + + #[test] + fn a_queue_lookup_failure_preserves_the_original_job() { + let original = Job::default(); + let resolved = resolve(&original, |_| Err(PrintError::Spool("driver unavailable".into()))); + assert!(matches!(resolved, Cow::Borrowed(_))); + assert_eq!(resolved.duplex, None); + assert_eq!(resolved.printer, original.printer); + } + + #[test] + fn failed_or_invalid_settings_fall_back_and_keep_the_resolved_queue() { + for value in [Err(PrintError::Spool("driver unavailable".into())), duplex(&settings(0x1000, 99))] { + let original = Job { copies: 3, ..Job::default() }; + let resolved = resolve(&original, |_| Ok(("Resolved queue".into(), driver_default(value)))); + assert_eq!(resolved.duplex, None); + assert_eq!(resolved.printer.as_deref(), Some("Resolved queue")); + assert_eq!(resolved.copies, 3); + assert_eq!(original.printer, None); + } + } + + #[test] + fn manual_duplex_defaults_remain_unspecified_without_automatic_support() { + for binding in [2, 3] { + for support in [Some(false), None, Some(true)] { + let original = Job { printer: Some("Manual duplex".into()), ..Job::default() }; + let value = duplex(&settings(0x1000, binding)).unwrap(); + let resolved = resolve(&original, |_| Ok(("Manual duplex".into(), supported_default(value, || support)))); + assert_eq!(resolved.duplex, if support == Some(true) { value } else { None }); + assert_eq!(resolved.printer.as_deref(), Some("Manual duplex")); + assert_eq!(original.duplex, None); + } + } + assert_eq!(supported_default(Some(Duplex::Off), || panic!("simplex needs no capability query")), Some(Duplex::Off)); + assert_eq!(supported_default(None, || panic!("unspecified needs no capability query")), None); + } + + /// Reads settings only: never submits a job or changes the printer's preferences. + #[cfg(windows)] + #[test] + #[ignore = "needs PDFCRAFT_PRINT_TEST_PRINTER naming an installed Windows printer; sends no job"] + fn installed_windows_printer_default_is_carried_explicitly() { + let printer = std::env::var("PDFCRAFT_PRINT_TEST_PRINTER").unwrap(); + let original = Job { printer: Some(printer.clone()), ..Job::default() }; + let expected = supported_default(duplex(&craft_print::rgb::default_settings(&printer).unwrap()).unwrap(), || { + super::super::printers().into_iter().find(|p| p.name == printer).and_then(|p| p.two_sided) + }); + let resolved = job(&original); + assert_eq!(resolved.printer.as_deref(), Some(printer.as_str())); + assert_eq!(resolved.duplex, expected); + eprintln!("native Windows default: {expected:?}; resolved job: {:?}", resolved.duplex); + } +} diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 00fcadb758..b17b66bb7a 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -288,15 +288,58 @@ fn imposing_lists_the_pages_once() { #[test] fn booklets_pair_pages_for_folding() { let sizes = vec![(200.0, 300.0); 6]; - let sheets = layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left })).unwrap(); + let sheets = + layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep: 0.0 })) + .unwrap(); // 6 pages pad to 8: sheet 1 front [8,1] → [blank, 0], back [1, 6]; sheet 2 front [5, 2], back [3, 4]. assert_eq!(sheet_pages(&sheets), [vec![0], vec![1], vec![5, 2], vec![3, 4]]); assert!(sheets[0].placed[0].matrix.0[4] >= 395.9, "page 1 on the right half"); assert_eq!(sheets[0].size, (792.0, 612.0)); - let front = layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::FrontOnly, binding: Binding::Right })).unwrap(); + let front = + layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::FrontOnly, binding: Binding::Right, creep: 0.0 })) + .unwrap(); assert_eq!(sheet_pages(&front), [vec![0], vec![2, 5]], "right binding mirrors the spread"); } +/// #6777: creep moves each sheet's pages toward the fold by its place from the outside, and clips +/// them at the fold, for either binding. +#[test] +fn booklet_creep_moves_inner_sheets_toward_the_fold() { + let sizes = vec![(200.0, 300.0); 8]; + let booklet = + |binding, creep| layout(&sizes, &settings((0..8).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding, creep })).unwrap(); + for binding in [Binding::Left, Binding::Right] { + let (flat, crept) = (booklet(binding, 0.0), booklet(binding, 2.0)); + assert_eq!(sheet_pages(&flat), sheet_pages(&crept)); + let fold = 792.0 / 2.0; + for (side, (a, b)) in flat.iter().zip(&crept).enumerate() { + let shift = if side < 2 { 0.0 } else { 2.0 }; + let (left, right) = ((&a.placed[0], &b.placed[0]), (&a.placed[1], &b.placed[1])); + assert!((left.1.matrix.0[4] - left.0.matrix.0[4] - shift).abs() < 1e-9, "{binding:?} side {side}: the left page moves right"); + assert!((right.1.matrix.0[4] - right.0.matrix.0[4] + shift).abs() < 1e-9, "{binding:?} side {side}: the right page moves left"); + let s = left.1.matrix.0[0]; + assert!(left.1.matrix.0[4] + left.1.clip[2] * s <= fold + 1e-9, "{binding:?} side {side}: the left page stops at the fold"); + assert!(right.1.matrix.0[4] + right.1.clip[0] * s >= fold - 1e-9, "{binding:?} side {side}: the right page starts at the fold"); + } + } +} + +#[test] +fn booklet_creep_is_refused_when_it_is_not_a_small_positive_amount() { + let sizes = vec![(200.0, 300.0); 8]; + let booklet = |sizes: &[(f64, f64)], creep| { + layout(sizes, &settings((0..sizes.len()).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep })) + }; + for creep in [f64::NAN, f64::INFINITY, -1.0, MAX_CREEP + 0.1] { + assert!(matches!(booklet(&sizes, creep), Err(PrintError::Invalid(_))), "{creep}"); + } + assert!(booklet(&sizes, MAX_CREEP).is_ok()); + // 400 pages fold into 100 sheets: 5 pt each would move the innermost past half its cell. + let thick = vec![(200.0, 300.0); 400]; + assert!(booklet(&thick, 1.0).is_ok()); + assert!(matches!(booklet(&thick, 5.0), Err(PrintError::Invalid(_))), "the innermost sheet moves too far"); +} + #[test] fn posters_tile_with_overlap() { // A 200×300 page at 400% = 800×1200 on Letter with 18 pt margins (576×756 tiles), overlap 36. @@ -709,9 +752,11 @@ fn landscape_sheets_ask_cups_to_turn_the_page() { // A landscape Size sheet, and a booklet (always landscape sheets), are both turned. let page = layout(&[(300.0, 200.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); assert_eq!(cups_orientation(&page), ask); - let booklet = - layout(&[(200.0, 300.0); 4], &settings(vec![0, 1, 2, 3], Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left })) - .unwrap(); + let booklet = layout( + &[(200.0, 300.0); 4], + &settings(vec![0, 1, 2, 3], Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep: 0.0 }), + ) + .unwrap(); assert_eq!(cups_orientation(&booklet), ask); // Portrait sheets, mixed jobs and nothing at all say nothing. let portrait = layout(&[(200.0, 300.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); @@ -1384,6 +1429,7 @@ fn driver_options_take_the_ppds_own_names_in_the_ui_language() { } mod appearances; +mod font_names; #[test] fn repeated_content_references_share_one_decode_budget() { @@ -1598,7 +1644,7 @@ fn missing_print_dlls_are_named() { #[cfg(windows)] #[test] fn printing_without_the_print_dlls_is_refused_before_the_spooler() { - use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST, printers, submit, submit_with_progress}; + use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST, printer_queues, printers, submit, submit_with_progress, two_sided}; let dir = std::env::temp_dir().join(format!("pdfcraft-print-no-dlls-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); @@ -1615,6 +1661,8 @@ fn printing_without_the_print_dlls_is_refused_before_the_spooler() { // Not only an empty list, which a machine without printers gives anyway: the backend wasn't asked. PRINTER_BACKEND_CALLS.with(|c| c.set(0)); assert!(printers().is_empty(), "no printers are listed"); + assert!(printer_queues().is_empty(), "no queues are listed"); + assert_eq!(two_sided("PdfCraft test: no such printer"), None, "two-sided support is unknown"); assert_eq!(PRINTER_BACKEND_CALLS.with(|c| c.get()), 0, "the printer backend wasn't asked"); SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = None); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs index 0d3107d1d7..f63ed14733 100644 --- a/crates/print/src/tests/appearances.rs +++ b/crates/print/src/tests/appearances.rs @@ -85,6 +85,44 @@ fn xobject(doc: &Document, owner: &Dict, name: &[u8]) -> ObjRef { objects.as_dict().unwrap().get(name).unwrap().as_ref().unwrap() } +/// Existing Link artwork prints as document content only when its flags allow printing. +#[test] +fn printable_link_appearance_survives_vector_print() { + for flags in [0, 2, 4, 6, 5] { + for content in [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly] { + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name("Link")); + d.set(b"F".to_vec(), Object::Int(flags)); + }) + .unwrap(); + let before = saved(&doc); + let expected = flags & 4 != 0 && flags & 2 == 0 && content != Content::FormFieldsOnly; + let printed = render(impose(&doc, &print_settings(content)).unwrap()); + let color = if expected { [0, 255, 0, 255] } else { [255, 255, 255, 255] }; + assert_eq!(pixel(&printed, 30, 70), color, "flags={flags}, content={content:?}"); + let mut raster_source = doc.clone(); + keep_printed(&mut raster_source, content); + let page = &pdfcraft_model::pages(&raster_source)[0]; + let annots = raster_source.resolve(page.dict.get(b"Annots").unwrap()); + assert_eq!(annots.as_array().unwrap().len(), usize::from(expected), "raster flags={flags}, content={content:?}"); + assert_eq!(saved(&doc), before, "source remains unchanged"); + } + } + for subtype in ["Link", "Popup"] { + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + if subtype == "Link" { + d.remove(b"AP"); + } + }) + .unwrap(); + let printed = render(impose(&doc, &print_settings(Content::DocumentAndMarkups)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255, 255, 255, 255], "{subtype} must not acquire an appearance"); + } +} + /// Printing keeps occupied page resource names and distinct annotation appearances intact, /// whether the page resources are direct or inherited from the page tree. #[test] diff --git a/crates/print/src/tests/font_names.rs b/crates/print/src/tests/font_names.rs new file mode 100644 index 0000000000..200d35ebee --- /dev/null +++ b/crates/print/src/tests/font_names.rs @@ -0,0 +1,507 @@ +//! #3132: embedded TrueType programs go to the Windows spooler under names no installed font has. + +use super::*; +use crate::font_names::{distinct, distinct_within, renamed_sfnt}; +use crate::spool::spooled_pdf; +use pdfcraft_cos::DecodeBudget; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use skrifa::raw::TableProvider as _; +use skrifa::string::StringId; +use skrifa::{FontRef, MetadataProvider as _}; + +/// Inter (OFL, `ATTRIBUTION.toml`) stands in for the subset ReportLab embeds as `AAAAAA+ArialMT`. +const INTER: &[u8] = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + +fn name_of(program: &[u8], id: StringId) -> Option { + FontRef::new(program).unwrap().localized_strings(id).english_or_first().map(|s| s.to_string()) +} + +/// A TrueType font over `program`, named `base_font`, as ReportLab writes one; its descriptor is +/// indirect. +fn true_type_font(doc: &mut Document, base_font: &str, program: ObjRef) -> ObjRef { + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(base_font)); + descriptor.set(b"Flags".to_vec(), Object::Int(32)); + descriptor.set(b"FontBBox".to_vec(), Object::Array([-500, -300, 1500, 1000].into_iter().map(Object::Int).collect())); + for (key, value) in [(b"ItalicAngle".as_slice(), 0), (b"Ascent", 900), (b"Descent", -200), (b"CapHeight", 700), (b"StemV", 80)] { + descriptor.set(key.to_vec(), Object::Int(value)); + } + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let descriptor = doc.add(Object::Dict(descriptor)); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("TrueType")); + font.set(b"BaseFont".to_vec(), Object::name(base_font)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + font.set(b"FontDescriptor".to_vec(), Object::Ref(descriptor)); + doc.add(Object::Dict(font)) +} + +fn program_stream(doc: &mut Document) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Length1".to_vec(), Object::Int(INTER.len() as i64)); + doc.add(Object::Stream(Stream::flate(d, INTER))) +} + +/// One 200×300 page writing "Hola" in a regular and a bold Arial subset, the way the report's +/// proposal is set, and its print-ready PDF. +fn print_ready() -> Vec { + let mut doc = fixture(1); + let (regular, bold) = (program_stream(&mut doc), program_stream(&mut doc)); + let regular = true_type_font(&mut doc, "AAAAAA+ArialMT", regular); + let bold = true_type_font(&mut doc, "AAAAAA+Arial-BoldMT", bold); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(regular)); + fonts.set(b"F2".to_vec(), Object::Ref(bold)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"BT /F1 40 Tf 10 200 Td (Hola) Tj ET BT /F2 40 Tf 10 100 Td (Hola) Tj ET"))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| { + d.remove(b"Annots"); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + }) + .unwrap(); + let settings = Settings { pages: vec![0], paper: (200.0, 300.0), layout: Layout::Size(SizeMode::Actual), ..Settings::default() }; + impose(&doc, &settings).unwrap() +} + +fn pixels(pdf: &[u8]) -> Vec { + let page = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert_eq!(page.error, None); + page.rgba[..].to_vec() +} + +/// Each TrueType font in `pdf`: its `/BaseFont`, its descriptor's `/FontName`, its decoded +/// program and that program's `/Length1`. +fn true_type_fonts(pdf: &[u8]) -> Vec<(String, String, Vec, i64)> { + let doc = Document::open(Arc::new(pdf.to_vec())).unwrap(); + let mut fonts = Vec::new(); + for (_, object) in doc.scan_objects() { + let Object::Dict(font) = object.as_ref() else { continue }; + if font.name(b"Subtype") != Some(b"TrueType") { + continue; + } + let descriptor = doc.dict(font.get(b"FontDescriptor").unwrap()).unwrap(); + let program = doc.resolve(descriptor.get(b"FontFile2").unwrap()); + let Object::Stream(program) = program.as_ref() else { panic!("no program") }; + let name = |d: &Dict, key: &[u8]| String::from_utf8(d.name(key).unwrap().to_vec()).unwrap(); + fonts.push((name(font, b"BaseFont"), name(&descriptor, b"FontName"), program.decoded().unwrap(), program.dict.int(b"Length1").unwrap())); + } + fonts.sort(); + fonts +} + +#[test] +fn windows_gets_embedded_subsets_under_names_no_installed_font_has() { + let pdf = print_ready(); + let before = true_type_fonts(&pdf); + assert_eq!(before.iter().map(|f| f.0.as_str()).collect::>(), ["AAAAAA+Arial-BoldMT", "AAAAAA+ArialMT"]); + + let spooled = spooled_pdf(&pdf, true); + let after = true_type_fonts(&spooled); + + // Each program has a name of its own, in the PDF and in its `name` table; the subset tag stays. + let names: Vec<_> = after.iter().map(|f| (f.0.as_str(), f.1.as_str())).collect(); + assert_eq!(names, [("AAAAAA+PdfCraftPrint1", "AAAAAA+PdfCraftPrint1"), ("AAAAAA+PdfCraftPrint2", "AAAAAA+PdfCraftPrint2")]); + for (n, (_, _, program, length1)) in after.iter().enumerate() { + let name = format!("PdfCraftPrint{}", n + 1); + for id in [StringId::FAMILY_NAME, StringId::FULL_NAME, StringId::POSTSCRIPT_NAME, StringId::UNIQUE_ID] { + assert_eq!(name_of(program, id).as_deref(), Some(name.as_str()), "{id:?}"); + } + assert_eq!(name_of(program, StringId::SUBFAMILY_NAME).as_deref(), Some("Regular")); + assert_eq!(*length1, program.len() as i64); + // Nothing that draws a glyph changed: the same glyph for every character, and the same + // tables but `name` and `head`'s checksum adjustment. + let (old, new) = (FontRef::new(INTER).unwrap(), FontRef::new(program).unwrap()); + assert_eq!(old.charmap().mappings().collect::>(), new.charmap().mappings().collect::>()); + let table = |font: &FontRef, tag| { + let mut data = font.table_data(tag).unwrap().as_bytes().to_vec(); + if &tag == b"head" { + data[8..12].fill(0); + } + data + }; + for tag in old.table_directory.table_records().iter().map(|r| r.tag()).filter(|t| t != b"name") { + assert_eq!(table(&old, tag), table(&new, tag), "{tag}"); + } + // `head`'s checksum adjustment holds for the new file. + let sum = program.chunks(4).fold(0_u32, |s, c| s.wrapping_add(u32::from_be_bytes([0, 1, 2, 3].map(|i| c.get(i).copied().unwrap_or(0))))); + assert_eq!(sum, 0xb1b0afba); + } + assert!(before.iter().all(|f| name_of(&f.2, StringId::FAMILY_NAME).as_deref() == Some("Inter"))); + + // The page draws exactly as before, and draws something. + let (old, new) = (pixels(&pdf), pixels(&spooled)); + assert!(old.chunks(4).any(|p| p[0] < 128), "the text is drawn"); + assert!(old == new, "renaming changed what prints"); +} + +#[test] +fn other_platforms_and_pdfs_without_embedded_programs_spool_as_they_are() { + let pdf = print_ready(); + assert!(matches!(spooled_pdf(&pdf, false), std::borrow::Cow::Borrowed(p) if p == pdf.as_slice())); + let helvetica = impose(&fixture(1), &Settings { pages: vec![0], paper: (200.0, 300.0), ..Settings::default() }).unwrap(); + assert_eq!(distinct(&helvetica).unwrap(), None); + assert!(matches!(spooled_pdf(&helvetica, true), std::borrow::Cow::Borrowed(_))); + // Not a PDF: it goes as it is, and the spooler reports what it makes of it. + assert!(matches!(spooled_pdf(b"not a pdf", true), std::borrow::Cow::Borrowed(b"not a pdf"))); +} + +#[test] +fn a_program_that_cannot_be_rewritten_keeps_its_name_in_the_pdf_too() { + // A viewer that can't load the program either picks a substitute by the PDF's name, so that + // name must still say Arial. + let mut doc = fixture(1); + let good = program_stream(&mut doc); + let mut d = Dict::new(); + d.set(b"Length1".to_vec(), Object::Int(10)); + let broken = doc.add(Object::Stream(Stream::flate(d, b"not a font"))); + let good = true_type_font(&mut doc, "AAAAAA+ArialMT", good); + let broken = true_type_font(&mut doc, "BAAAAA+Arial-BoldMT", broken); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(good)); + fonts.set(b"F2".to_vec(), Object::Ref(broken)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let pdf = write_full(&doc, &SaveOptions::default()).unwrap(); + + let names: Vec<_> = true_type_fonts(&spooled_pdf(&pdf, true)).into_iter().map(|f| (f.0, f.1, f.2 == b"not a font")).collect(); + assert_eq!( + names, + [ + ("AAAAAA+PdfCraftPrint1".to_owned(), "AAAAAA+PdfCraftPrint1".to_owned(), false), + ("BAAAAA+Arial-BoldMT".to_owned(), "BAAAAA+Arial-BoldMT".to_owned(), true), + ] + ); + // With nothing it can rewrite, the PDF goes as it is. + let mut fonts = Dict::new(); + fonts.set(b"F2".to_vec(), Object::Ref(broken)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + assert_eq!(distinct(&write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(), None); +} + +#[test] +fn programs_past_the_shared_decode_budget_keep_their_names() { + // Each program decodes to Inter's whole length; the budget covers one and a half. + let pdf = print_ready(); + let mut budget = DecodeBudget::new(INTER.len() * 3 / 2, 0); + let renamed = distinct_within(&pdf, &mut budget).unwrap().unwrap(); + let names: Vec<_> = true_type_fonts(&renamed).into_iter().map(|f| (f.0, name_of(&f.2, StringId::FAMILY_NAME).unwrap())).collect(); + let (new, old): (Vec<_>, Vec<_>) = names.iter().partition(|(base_font, _)| base_font.contains("PdfCraftPrint")); + assert_eq!((new.len(), old.len()), (1, 1), "{names:?}"); + assert!(new[0].0.starts_with("AAAAAA+PdfCraftPrint") && new[0].1.starts_with("PdfCraftPrint"), "{names:?}"); + assert!(old[0].0.starts_with("AAAAAA+Arial") && old[0].1 == "Inter", "{names:?}"); + // With the default allowance both are renamed. + assert_eq!(true_type_fonts(&distinct(&pdf).unwrap().unwrap()).iter().filter(|f| f.0.contains("PdfCraftPrint")).count(), 2); +} + +/// A CIDFontType2 named `base_font` over `program`, written as a dictionary with its descriptor +/// inline, as some producers put it straight into `/DescendantFonts`. +fn inline_cid_font(base_font: &str, program: ObjRef) -> Dict { + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(base_font)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut cid = Dict::new(); + cid.set(b"Type".to_vec(), Object::name("Font")); + cid.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + cid.set(b"BaseFont".to_vec(), Object::name(base_font)); + cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + cid +} + +fn type0_font(doc: &mut Document, base_font: &str, descendants: Object) -> ObjRef { + let mut type0 = Dict::new(); + type0.set(b"Type".to_vec(), Object::name("Font")); + type0.set(b"Subtype".to_vec(), Object::name("Type0")); + type0.set(b"BaseFont".to_vec(), Object::name(base_font)); + type0.set(b"Encoding".to_vec(), Object::name("Identity-H")); + type0.set(b"DescendantFonts".to_vec(), descendants); + doc.add(Object::Dict(type0)) +} + +#[test] +fn a_cidfont_written_inside_its_type0_font_is_renamed_with_it() { + let mut doc = fixture(1); + let (shared, own) = (program_stream(&mut doc), program_stream(&mut doc)); + let simple = true_type_font(&mut doc, "AAAAAA+ArialMT", shared); + // In `/DescendantFonts` itself, sharing the simple font's program; and in an array object of + // its own, over a program nothing else uses. + let direct = type0_font(&mut doc, "BAAAAA+ArialMT-Identity-H", Object::Array(vec![Object::Dict(inline_cid_font("BAAAAA+ArialMT", shared))])); + let array = doc.add(Object::Array(vec![Object::Dict(inline_cid_font("CAAAAA+ArialMT", own))])); + let in_array = type0_font(&mut doc, "CAAAAA+ArialMT-Identity-H", Object::Ref(array)); + let mut fonts = Dict::new(); + for (key, font) in [(b"F1", simple), (b"F2", direct), (b"F3", in_array)] { + fonts.set(key.to_vec(), Object::Ref(font)); + } + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let pdf = write_full(&doc, &SaveOptions::default()).unwrap(); + + let renamed = Document::open(Arc::new(distinct(&pdf).unwrap().unwrap())).unwrap(); + let page = renamed.dict(&Object::Ref(pdfcraft_model::pages(&renamed)[0].obj)).unwrap(); + let fonts = renamed.dict(renamed.dict(page.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap(); + let name = |d: &Dict, key: &[u8]| String::from_utf8(d.name(key).unwrap().to_vec()).unwrap(); + let simple = renamed.dict(fonts.get(b"F1").unwrap()).unwrap(); + for (key, tag) in [(b"F2", "BAAAAA+"), (b"F3", "CAAAAA+")] { + let type0 = renamed.dict(fonts.get(key).unwrap()).unwrap(); + let descendants = renamed.resolve(type0.get(b"DescendantFonts").unwrap()); + let Some(Object::Dict(cid)) = descendants.as_array().and_then(|a| a.first()) else { panic!("inline CIDFont") }; + let Some(Object::Dict(descriptor)) = cid.get(b"FontDescriptor") else { panic!("inline descriptor") }; + let program = renamed.resolve(descriptor.get(b"FontFile2").unwrap()); + let Object::Stream(program) = program.as_ref() else { panic!("program") }; + let family = name_of(&program.decoded().unwrap(), StringId::FAMILY_NAME).unwrap(); + // The Type 0 font, its CIDFont and the descriptor all say the program's new name. + let expected = format!("{tag}{family}"); + assert!(family.starts_with("PdfCraftPrint"), "{family}"); + assert_eq!( + (name(&type0, b"BaseFont"), name(cid, b"BaseFont"), name(descriptor, b"FontName")), + (expected.clone(), expected.clone(), expected) + ); + } + // The shared program has one name, whichever font led to it. + let shared_name = name(&simple, b"BaseFont").replace("AAAAAA+", ""); + assert_eq!(name(&renamed.dict(fonts.get(b"F2").unwrap()).unwrap(), b"BaseFont"), format!("BAAAAA+{shared_name}")); +} + +#[test] +fn fonts_sharing_a_program_share_its_name_and_type0_fonts_follow_their_cidfont() { + let mut doc = fixture(1); + let program = program_stream(&mut doc); + let simple = true_type_font(&mut doc, "BCDEFG+Calibri", program); + let again = true_type_font(&mut doc, "Calibri", program); + // A Type 0 font over a CIDFontType2 whose descriptor is written inline. + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name("HIJKLM+Calibri")); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut cid = Dict::new(); + cid.set(b"Type".to_vec(), Object::name("Font")); + cid.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + cid.set(b"BaseFont".to_vec(), Object::name("HIJKLM+Calibri")); + cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + let cid = doc.add(Object::Dict(cid)); + let mut type0 = Dict::new(); + type0.set(b"Type".to_vec(), Object::name("Font")); + type0.set(b"Subtype".to_vec(), Object::name("Type0")); + type0.set(b"BaseFont".to_vec(), Object::name("HIJKLM+Calibri-Identity-H")); + type0.set(b"Encoding".to_vec(), Object::name("Identity-H")); + type0.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Ref(cid)])); + let type0 = doc.add(Object::Dict(type0)); + let mut fonts = Dict::new(); + for (key, font) in [(b"F1", simple), (b"F2", again), (b"F3", type0)] { + fonts.set(key.to_vec(), Object::Ref(font)); + } + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let pdf = write_full(&doc, &SaveOptions::default()).unwrap(); + + // The file is written anew, so the fonts are found again through the page. + let renamed = Document::open(Arc::new(distinct(&pdf).unwrap().unwrap())).unwrap(); + let page = renamed.dict(&Object::Ref(pdfcraft_model::pages(&renamed)[0].obj)).unwrap(); + let fonts = renamed.dict(renamed.dict(page.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap(); + let font = |key: &[u8]| renamed.dict(fonts.get(key).unwrap()).unwrap(); + let base_font = |d: &Dict| String::from_utf8(d.name(b"BaseFont").unwrap().to_vec()).unwrap(); + assert_eq!(base_font(&font(b"F1")), "BCDEFG+PdfCraftPrint1"); + assert_eq!(base_font(&font(b"F2")), "PdfCraftPrint1"); + assert_eq!(base_font(&font(b"F3")), "HIJKLM+PdfCraftPrint1"); + let Some(Object::Array(descendants)) = font(b"F3").get(b"DescendantFonts").cloned() else { panic!("descendants") }; + let cid = renamed.dict(&descendants[0]).unwrap(); + assert_eq!(base_font(&cid), "HIJKLM+PdfCraftPrint1"); + let Some(Object::Dict(inline)) = cid.get(b"FontDescriptor") else { panic!("inline descriptor") }; + assert_eq!(inline.name(b"FontName"), Some(b"HIJKLM+PdfCraftPrint1".as_slice())); + // Renamed once, not once per font. + let program = renamed.resolve(inline.get(b"FontFile2").unwrap()); + let Object::Stream(program) = program.as_ref() else { panic!("program") }; + assert_eq!(name_of(&program.decoded().unwrap(), StringId::FAMILY_NAME).as_deref(), Some("PdfCraftPrint1")); +} + +/// A `name` table of `format` with the given records `(platform, encoding, language, id, text)` +/// and language tags, inside a minimal sfnt with only that table. +fn sfnt_with_name(format: u16, records: &[(u16, u16, u16, u16, &[u8])], tags: &[&[u8]]) -> Vec { + let mut table = Vec::new(); + let mut strings = Vec::new(); + table.extend_from_slice(&format.to_be_bytes()); + table.extend_from_slice(&(records.len() as u16).to_be_bytes()); + let header = 6 + records.len() * 12 + if format == 1 { 2 + tags.len() * 4 } else { 0 }; + table.extend_from_slice(&(header as u16).to_be_bytes()); + for (platform, encoding, language, id, text) in records { + for v in [*platform, *encoding, *language, *id, text.len() as u16, strings.len() as u16] { + table.extend_from_slice(&v.to_be_bytes()); + } + strings.extend_from_slice(text); + } + if format == 1 { + table.extend_from_slice(&(tags.len() as u16).to_be_bytes()); + for tag in tags { + table.extend_from_slice(&(tag.len() as u16).to_be_bytes()); + table.extend_from_slice(&(strings.len() as u16).to_be_bytes()); + strings.extend_from_slice(tag); + } + } + table.extend_from_slice(&strings); + sfnt_with_table(&table) +} + +/// A minimal sfnt with `table` as its only table, `name`. +fn sfnt_with_table(table: &[u8]) -> Vec { + let mut sfnt = vec![0, 1, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0]; + sfnt.extend_from_slice(b"name"); + sfnt.extend_from_slice(&0_u32.to_be_bytes()); + sfnt.extend_from_slice(&28_u32.to_be_bytes()); + sfnt.extend_from_slice(&(table.len() as u32).to_be_bytes()); + sfnt.extend_from_slice(table); + sfnt +} + +/// A format-0 `name` table whose `copies` copyright records all point at one `length`-byte +/// string, then a family record after it; `storage` overrides where the table says its strings +/// start. +fn shared_string_table(copies: usize, length: usize, storage: Option) -> Vec { + let count = copies + 1; + let mut table = Vec::new(); + for v in [0, count as u16, storage.unwrap_or((6 + count * 12) as u16)] { + table.extend_from_slice(&v.to_be_bytes()); + } + let copyright = [3, 1, 0x409, 0, length as u16, 0]; + for v in (0..copies).flat_map(|_| copyright).chain([3, 1, 0x409, 1, 2, length as u16]) { + table.extend_from_slice(&v.to_be_bytes()); + } + table.extend(std::iter::repeat_n(b'c', length)); + table.extend_from_slice(&[0, b'A']); + table +} + +#[test] +fn records_that_share_one_string_keep_sharing_it() { + // A thousand records over one 60,000-byte string. Each given its own copy they would take + // 60 MB and lie past what 16-bit offsets reach; stored once, the table is as small as before. + let table = shared_string_table(1024, 60_000, None); + let renamed = renamed_sfnt(&sfnt_with_table(&table), "PdfCraftPrint1").unwrap(); + let font = FontRef::new(&renamed).unwrap(); + let name = font.name().unwrap(); + let copyright: std::collections::BTreeSet<_> = + name.name_record().iter().filter(|r| r.name_id().to_u16() == 0).map(|r| (r.string_offset().to_u32(), r.length())).collect(); + assert_eq!(copyright.len(), 1, "every copyright record points at the one string"); + assert!(font.table_data(skrifa::raw::types::Tag::new(b"name")).unwrap().len() <= table.len() + 64); + assert_eq!(name_of(&renamed, StringId::FAMILY_NAME).as_deref(), Some("PdfCraftPrint1")); + // 60,000 bytes of UTF-16BE: 30,000 characters. + assert_eq!(name_of(&renamed, StringId::COPYRIGHT_NOTICE).map(|c| c.chars().count()), Some(60_000 / 2)); +} + +#[test] +fn a_name_table_claiming_more_records_than_fit_before_its_strings_is_refused() { + // The case reported on #4098: 65,535 records over one 65,535-byte string, the strings said to + // start inside the records. Copying each record's string before checking took about 4 GiB. + let table = shared_string_table(65_534, 65_535, Some(6)); + assert_eq!(renamed_sfnt(&sfnt_with_table(&table), "X"), None); +} + +/// A `name` record: platform, encoding, language, name ID and the string's bytes. +type Record = (u16, u16, u16, u16, Vec); + +/// The records and language tags of the renamed font's `name` table. +fn records_of(sfnt: &[u8]) -> (Vec, Vec>) { + let font = FontRef::new(sfnt).unwrap(); + let name = font.name().unwrap(); + let data = name.string_data(); + let text = |length: u16, offset: u16| data.as_bytes()[offset as usize..][..length as usize].to_vec(); + let records = name + .name_record() + .iter() + .map(|r| (r.platform_id(), r.encoding_id(), r.language_id(), r.name_id().to_u16(), text(r.length(), r.string_offset().to_u32() as u16))) + .collect(); + let tags = name.lang_tag_record().unwrap_or_default().iter().map(|t| text(t.length(), t.lang_tag_offset().to_u32() as u16)).collect(); + (records, tags) +} + +#[test] +fn identity_records_are_respelled_per_platform_and_the_rest_are_kept() { + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let arial = utf16("Arial"); + let copyright = utf16("© The Monotype Corporation"); + let sjis_family: &[u8] = &[0x82, 0xa0]; + let sfnt = sfnt_with_name( + 1, + &[ + (1, 0, 0, 1, b"Arial"), + (1, 0, 0, 2, b"Bold"), + (3, 1, 0x409, 0, ©right), + (3, 1, 0x409, 1, &arial), + (3, 1, 0x409, 6, &utf16("Arial-BoldMT")), + (3, 2, 0x411, 1, sjis_family), + (3, 1, 0x8000, 4, &utf16("Arial Bold")), + ], + &[b"\0e\0n"], + ); + let renamed = renamed_sfnt(&sfnt, "PdfCraftPrint7").unwrap(); + let (records, tags) = records_of(&renamed); + assert_eq!( + records, + [ + (1, 0, 0, 1, b"PdfCraftPrint7".to_vec()), + (1, 0, 0, 2, b"Bold".to_vec()), + (3, 1, 0x409, 0, copyright), + (3, 1, 0x409, 1, utf16("PdfCraftPrint7")), + (3, 1, 0x409, 6, utf16("PdfCraftPrint7")), + // The Shift-JIS family can't be respelled in ASCII here, so it goes. + (3, 1, 0x8000, 4, utf16("PdfCraftPrint7")), + ] + ); + assert_eq!(tags, [b"\0e\0n".to_vec()]); +} + +#[test] +fn damaged_programs_are_left_alone_and_never_panic() { + let good = sfnt_with_name(0, &[(3, 1, 0x409, 1, &[0, b'A'])], &[]); + assert!(renamed_sfnt(&good, "X").is_some()); + // Every truncation, of the small font and of the start of a real one. + for n in 0..good.len() { + let _ = renamed_sfnt(&good[..n], "X"); + } + for n in (0..2048).chain([INTER.len() - 1]) { + let _ = renamed_sfnt(&INTER[..n], "X"); + } + // A collection, a CFF-in-name-only header, an absurd table count, a `name` entry past the + // end, a format-2 `name` table and a string past the table's end. + let mut collection = good.clone(); + collection[..4].copy_from_slice(b"ttcf"); + assert_eq!(renamed_sfnt(&collection, "X"), None); + let mut many = good.clone(); + many[4..6].copy_from_slice(&u16::MAX.to_be_bytes()); + assert_eq!(renamed_sfnt(&many, "X"), None); + let mut past = good.clone(); + past[20..24].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(renamed_sfnt(&past, "X"), None); + let mut format2 = good.clone(); + format2[28..30].copy_from_slice(&2_u16.to_be_bytes()); + assert_eq!(renamed_sfnt(&format2, "X"), None); + let mut long = sfnt_with_name(0, &[(3, 1, 0x409, 0, &[0, b'C']), (3, 1, 0x409, 1, &[0, b'A'])], &[]); + let first_length = 28 + 6 + 8; + long[first_length..first_length + 2].copy_from_slice(&u16::MAX.to_be_bytes()); + let (records, _) = records_of(&renamed_sfnt(&long, "X").unwrap()); + assert_eq!(records, [(3, 1, 0x409, 1, vec![0, b'X'])], "the broken copyright record is left out"); + // A broken language tag fails the table, since records point at tags by position. + let mut tagged = sfnt_with_name(1, &[(3, 1, 0x409, 1, &[0, b'A'])], &[b"\0e"]); + let tag_length = 28 + 6 + 12 + 2; + tagged[tag_length..tag_length + 2].copy_from_slice(&u16::MAX.to_be_bytes()); + assert_eq!(renamed_sfnt(&tagged, "X"), None); +} diff --git a/crates/redact/README.md b/crates/redact/README.md index 51ac11f086..e47df98f17 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -19,15 +19,35 @@ clear_marks(&mut doc, None)?; // remove marks without app inside; - **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax or JBIG2 as - the last filter, decoded through `Document::decoded_image_within`, a JBIG2 image with its + the last filter, decoded through `ImageBudget::decode`, a JBIG2 image with its `/JBIG2Globals`; `/DecodeParms` given by reference, numbers inside included, count). The copy is Flate, or for a one-bit image of one component (a scan as a rule) CCITT Group 4, or JBIG2 when it was JBIG2, whichever is smallest as stored, coded from the cleared samples; it refers to no globals. Globals hold the shapes of every image that shares them (a scan's pages, often), - those of marks under the area included, so every other image that uses the globals of a removed - or replaced image is decoded and stored again in place without them, as a copy is (Flate, - Group 4 or JBIG2, whichever is smallest; `Report::images_restored`), and they leave the file on the full rewrite; one that can't be - decoded for certain refuses the redaction (`RedactError::SharedJbig2Globals`). Codecs redaction doesn't decode (DCT, JPX) are + those of marks under the area included, so whatever left the document, by any route, takes its + globals with it on the full rewrite: anything else the save would keep that names them, or a + copy of them (the same bytes once decoded), is stored again without them, as a copy is (Flate, Group 4 or JBIG2, + whichever is smallest; `Report::images_restored`). When one can't be (it isn't JBIG2, its colour + space is known to have more than one component, it has no size or one past what redaction + clears, its data is in another file, or it can't be decoded), or something else refers to them, `apply` + refuses and changes nothing (`RedactError::SharedJbig2Globals`). `remove_hidden` takes them out + the same way, but it has changed the document by the time it refuses: a caller passes a copy it + can drop. Another dictionary holding the same shapes in other bytes isn't found, nor is a copy + nothing names, and an image that hasn't left keeps its globals with its pixels: one another page + still draws, or one something that doesn't draw it still refers to. What an image names as + globals is read by object number, whatever generation a reference gives, as a reader that doesn't + compare generations draws it (cos reads such a reference as null); what one is stored again with, + its size, filters and parameters, is read as cos reads it. Finding what to store again + reads each object at most once for each way it is read (a `/JBIG2Globals` chain named part way, + a ring of references or a chain of more than 33, or a value read through a reference of another + generation, may be read again), and what it fetches, remembers and decodes (a decode that + fails counting as much as it was allowed, and an image the dictionaries it names) counts against + one budget for the run; past it the redaction is refused (`RedactError::SharedJbig2Budget`). The + images stored again, and the dictionaries read to know copies of them, are decoded within the + run's image allowance, every filter stage and the JBIG2 decoder's own work included, after what + clearing spent: past it `apply` is refused as one clearing too many images is + (`RedactError::ImagesPastAllowance`), and `remove_hidden`, which chose no pages, as one past the + run's budget. Codecs redaction doesn't decode (DCT, JPX) are removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's @@ -148,8 +168,10 @@ used by surviving pages or forms are kept. 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. Surviving form XObjects lose the resource entries for removed XObjects their own -content doesn't draw, for the same reason. Pages that aren't redacted are left untouched. +XObject reachable. Surviving form XObjects lose the resource entries for removed XObjects that +neither their own content nor a form without resources of its own drawn through them still draws, +for the same reason, and a redacted page keeps a name under that rule too. Pages that aren't +redacted are left untouched. ## Remove hidden information / Sanitize diff --git a/crates/redact/src/globals.rs b/crates/redact/src/globals.rs new file mode 100644 index 0000000000..f12378a2fa --- /dev/null +++ b/crates/redact/src/globals.rs @@ -0,0 +1,637 @@ +//! A `/JBIG2Globals` stream is a dictionary of symbols that JBIG2 images can share, and scanners +//! and `jbig2enc -s` share one across a scan's pages. It holds the shape of every distinct mark of +//! those pages, including a mark found only under a redaction, so taking the image out isn't enough: +//! while something else uses the dictionary, a full save keeps it. [`unshare`] stores what uses it +//! again without it. + +use std::collections::hash_map::DefaultHasher; +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; +use std::rc::Rc; +use std::sync::Arc; + +use pdfcraft_cos::{CosError, DECODE_PARMS_KEYS, Dict, Document, ImageBudget, MAX_JBIG2_GLOBALS, Object, Stream, reachable_objects}; + +/// The references followed from `/JBIG2Globals` towards a stream, so that a ring of them ends. +const MAX_HOPS: usize = 32; + +/// The objects a full save would keep, by number, as [`reachable_objects`] finds them: a reference +/// whose generation isn't its object's reads as null there (#1143), so it keeps nothing. Fails only +/// when a full save couldn't number them all. +pub(crate) fn kept(doc: &Document) -> Result, CosError> { + reachable_objects(doc) +} + +/// `o`, or what references lead to from it. A ring of them ends as a reference, which is no dictionary. +pub(crate) fn followed(doc: &Document, o: &Object) -> Arc { + let mut at = doc.resolve(o); + for _ in 0..MAX_HOPS { + let next = match &*at { + Object::Ref(next) => *next, + _ => break, + }; + at = doc.get(next); + } + at +} + +/// The most one run may spend finding what shares a dictionary with what left: objects fetched, +/// names remembered, and decoded data, a unit and one for each whole 4 KiB. Past it the run is refused +/// ([`RedactError::SharedJbig2Budget`](crate::RedactError::SharedJbig2Budget)). A scan reads a few +/// objects a page, and its pages stored again a unit for each 4 KiB of their samples and of the +/// dictionary they name, so a book of thousands of pages sharing a dictionary of a MiB or two stays +/// within it. The work of decoding, every filter stage's and the JBIG2 decoder's, is paid besides +/// from the run's image allowance (`ImageBudget`), which a unit of decoded data says nothing of. +pub(crate) const RUN_BUDGET: usize = 1 << 22; + +/// What a parameters value names as `/JBIG2Globals`, and the key it is remembered by: a value the +/// file refers to, by the number of the object the references end at and whether it is read as a +/// list of entries. +pub(crate) type Listed = (Rc<[u32]>, Option); + +/// A value the file refers to: the number of an object, and whether it is read as a list of entries. +pub(crate) type Key = (u32, bool); + +/// How a value is read, and so what is kept of it: what a reference leads to is remembered for each. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum How { + /// A size: a number, or null. + Number, + /// One filter: a name, or null. + Name, + /// `/Filter`: a name, or an array of names, or null for a chain longer than any decoder takes. + Filter, + /// `/DecodeParms` for so many filters. + Parms(usize), + /// One parameters entry: the numbers and flags decoders read, and `/JBIG2Globals` by reference. + Entry, + /// A number or a flag. + Scalar, + /// `/F`: whether it names anything (true) or is null. + Present, + /// `/ColorSpace`: whether it is known to have more than one component. + Components, + /// An ICC profile: its `/N`, or null. + Profile, +} + +/// What one redaction or Remove Hidden Information run reads, to find what shares a dictionary with +/// what left, against [`RUN_BUDGET`]. What a stream names is read by object number, whatever +/// generation a reference gives ([`Run::fetch_numbered`]); what one is stored again with is read as +/// cos reads it, by number and generation, so a reference of another generation reads its own way. +/// Each object is fetched at most once a run for each way it is read, and kept only as what is read +/// of it: a value many streams share, by reference, through a +/// chain of them or through an entry, costs it once however many there are. The exceptions are a +/// `/JBIG2Globals` chain named part way, which is followed again from there, and a ring of +/// references or a chain past the limit, read again (up to 33 fetches) wherever it is met; every +/// fetch counts. +pub(crate) struct Run { + budget: usize, + /// What was spent of the budget. + pub(crate) spent: usize, + /// What a reference leads to, as it is read, under every reference on the way, by number and + /// generation: one whose generation isn't its object's reads as null (#1143), and must not give + /// another reference to that number what it read, nor take what that one read. + reduced: HashMap<(pdfcraft_cos::ObjRef, How), Rc>, + /// What a reference leads to names, under every reference on the way, with the key of the one + /// that names what it ends at: aliases and chains find it, and are counted as that one. + names: HashMap, Key)>, + /// What a `/JBIG2Globals` reference leads to: it and each reference after it, by its number. + chains: HashMap>, + /// What each dictionary was found to decode to, or its limit when it couldn't be: an image + /// naming it may undo its filters again, which costs as much. + sizes: HashMap, + /// How many values were read for their names rather than remembered. + pub(crate) read: usize, + /// How many objects were fetched. + pub(crate) fetched: usize, +} + +impl Run { + pub(crate) fn new(budget: usize) -> Self { + Run { budget, spent: 0, reduced: HashMap::new(), names: HashMap::new(), chains: HashMap::new(), sizes: HashMap::new(), read: 0, fetched: 0 } + } + + /// Counts `units` against the budget. + fn spend(&mut self, units: usize) -> Result<(), crate::RedactError> { + self.spent = self.spent.saturating_add(units); + if self.spent > self.budget { Err(crate::RedactError::SharedJbig2Budget) } else { Ok(()) } + } + + /// Fetches the object `r` names, counted. + fn fetch(&mut self, doc: &Document, r: pdfcraft_cos::ObjRef) -> Result, crate::RedactError> { + self.spend(1)?; + self.fetched += 1; + Ok(doc.get(r)) + } + + /// Fetches the object of `r`'s number, whatever generation `r` gives, counted. Cos reads a + /// reference whose generation isn't its object's as null (#1143), but a reader that doesn't + /// compare generations draws with the object: what an image that left names this way may hold + /// its shapes, so it is named, as what names it this way is a user of it. + fn fetch_numbered(&mut self, doc: &Document, r: pdfcraft_cos::ObjRef) -> Result, crate::RedactError> { + self.fetch(doc, pdfcraft_cos::ObjRef::new(r.num, doc.generation(r.num))) + } + + /// What `v` leads to through references, as `followed` goes, kept as `read` makes it. Each + /// reference is looked up before it is fetched, and what it leads to is remembered under every + /// one on the way, so one remembered costs nothing more, and a chain only the references before + /// it. A reference of another generation is looked up as itself, and read as cos reads it. A + /// ring, or a chain past the limit, is read as the reference it ends at. + fn follow( + &mut self, + doc: &Document, + v: &Object, + how: How, + read: impl FnOnce(&mut Self, &Document, &Object) -> Result, + ) -> Result, crate::RedactError> { + let mut keys: Vec<(pdfcraft_cos::ObjRef, How)> = Vec::new(); + let mut fetched: Option> = None; + for _ in 0..=MAX_HOPS { + let Some(r) = fetched.as_deref().unwrap_or(v).as_ref() else { break }; + if let Some(hit) = self.reduced.get(&(r, how)).cloned() { + for k in keys { + self.reduced.insert(k, hit.clone()); + } + return Ok(hit); + } + keys.push((r, how)); + fetched = Some(self.fetch(doc, r)?); + } + let made = Rc::new(read(self, doc, fetched.as_deref().unwrap_or(v))?); + for k in keys { + self.reduced.insert(k, made.clone()); + } + Ok(made) + } + + /// What `s` names as `/JBIG2Globals`, and each reference on the way to one: in any entry of its + /// parameters, whichever filter that entry belongs to and however the filters are given, one + /// list for each key that gives it parameters. A reader may use less of this, but leaving a + /// dictionary behind is the failure that matters here. + pub(crate) fn names_of(&mut self, doc: &Document, s: &Stream) -> Result, crate::RedactError> { + // `/FDecodeParms` too: those of data in another file, which a reader may draw instead. + [b"DecodeParms".as_slice(), b"DP", b"FDecodeParms"] + .into_iter() + .filter_map(|key| s.dict.get(key)) + .map(|v| self.names_in(doc, v, true)) + .collect() + } + + /// What `v` names: one entry, or (`list`) an array of them or one. + fn names_in(&mut self, doc: &Document, v: &Object, list: bool) -> Result { + let mut keys: Vec = Vec::new(); + let mut fetched: Option> = None; + for _ in 0..=MAX_HOPS { + let Some(r) = fetched.as_deref().unwrap_or(v).as_ref() else { break }; + let key = (r.num, list); + if let Some((hit, at)) = self.names.get(&key).cloned() { + for k in keys { + self.names.insert(k, (hit.clone(), at)); + } + return Ok((hit, Some(at))); + } + keys.push(key); + fetched = Some(self.fetch_numbered(doc, r)?); + } + self.read += 1; + let held = fetched.as_deref().unwrap_or(v); + let names: Rc<[u32]> = match (held, list) { + (Object::Array(entries), true) => { + // A set as it is read, so many entries naming the same holds them once. + let mut all = std::collections::BTreeSet::new(); + for e in entries { + all.extend(self.names_in(doc, e, false)?.0.iter()); + } + all.into_iter().collect() + } + (entry, _) => self.entry_names(doc, entry)?.into(), + }; + // Remembered when the references end at an object, under the last of them, and counted + // against the budget as held; a ring, or a chain past the limit, is read again where met. + let at = keys.last().copied().filter(|_| !matches!(held, Object::Ref(_))); + if let Some(at) = at { + self.spend(names.len())?; + for k in keys { + self.names.insert(k, (names.clone(), at)); + } + } + Ok((names, at)) + } + + /// What one parameters entry names as `/JBIG2Globals`, and each reference on the way to it. + fn entry_names(&mut self, doc: &Document, entry: &Object) -> Result, crate::RedactError> { + match entry.as_dict().and_then(|d| d.get(b"JBIG2Globals")).and_then(Object::as_ref) { + Some(start) => Ok(self.chain(doc, start)?.to_vec()), + None => Ok(Vec::new()), + } + } + + /// `start` and each reference after it, up to the limit, remembered under `start` for the run, + /// so entries naming it, directly or not, fetch it once however many there are; a chain that + /// reaches the start of one already remembered takes what is known from there. + fn chain(&mut self, doc: &Document, start: pdfcraft_cos::ObjRef) -> Result, crate::RedactError> { + if let Some(hit) = self.chains.get(&start.num) { + return Ok(hit.clone()); + } + let mut found = Vec::new(); + let mut at = start; + for _ in 0..=MAX_HOPS { + if let Some(rest) = self.chains.get(&at.num) { + found.extend(rest.iter()); + break; + } + found.push(at.num); + match &*self.fetch_numbered(doc, at)? { + Object::Ref(next) => at = *next, + _ => break, + } + } + found.truncate(MAX_HOPS + 1); + let found: Rc<[u32]> = found.into(); + self.spend(found.len())?; + self.chains.insert(start.num, found.clone()); + Ok(found) + } + + /// A size through any references: a number, or null. + fn number(&mut self, doc: &Document, v: &Object) -> Result { + let read = |_: &mut Self, _: &Document, o: &Object| Ok(if matches!(o, Object::Int(_) | Object::Real(_)) { o.clone() } else { Object::Null }); + Ok((*self.follow(doc, v, How::Number, read)?).clone()) + } + + /// A `/Filter` through any references, as a decoder reads it: a name a decoder knows, or an array + /// of them, each entry through references too. Anything else is null, a chain longer than any + /// decoder takes (`MAX_CHAIN`, and a `/Crypt`) included, so a stream that names it is read as one + /// with no filter. + fn filter(&mut self, doc: &Document, v: &Object) -> Result { + // A name a decoder knows; any other is no filter, and isn't copied, however long. + fn name(_: &mut Run, _: &Document, o: &Object) -> Result { + let known = |n: &[u8]| n.len() <= 16 && !matches!(pdfcraft_filters::Filter::from_name(n), pdfcraft_filters::Filter::Unknown(_)); + Ok(match o { + Object::Name(n) if known(n) => o.clone(), + _ => Object::Null, + }) + } + let read = |run: &mut Self, doc: &Document, o: &Object| match o { + Object::Array(a) if a.len() > pdfcraft_filters::MAX_CHAIN + 1 => Ok(Object::Null), + Object::Array(a) => { + let mut names = Vec::with_capacity(a.len()); + for e in a { + names.push((*run.follow(doc, e, How::Name, name)?).clone()); + } + Ok(Object::Array(names)) + } + other => name(run, doc, other), + }; + Ok((*self.follow(doc, v, How::Filter, read)?).clone()) + } + + /// `/DecodeParms` as a reader takes them, through any references, for `filters` filters: of each + /// entry, the numbers and flags the decoders read ([`DECODE_PARMS_KEYS`]), and `/JBIG2Globals` when + /// it is a reference (a direct value is no stream, so no decoder reads it, and it may be large). + /// An entry that isn't a dictionary is null, as `Document::with_direct_parms` reads them. + fn parms(&mut self, doc: &Document, v: &Object, filters: usize) -> Result { + fn scalar(_: &mut Run, _: &Document, o: &Object) -> Result { + Ok(if matches!(o, Object::Int(_) | Object::Real(_) | Object::Bool(_)) { o.clone() } else { Object::Null }) + } + fn entry(run: &mut Run, doc: &Document, o: &Object) -> Result { + let Object::Dict(d) = o else { return Ok(Object::Null) }; + let mut made = Dict::new(); + for key in DECODE_PARMS_KEYS { + let Some(value) = d.get(key) else { continue }; + let value = run.follow(doc, value, How::Scalar, scalar)?; + if !matches!(*value, Object::Null) { + made.set(key.to_vec(), (*value).clone()); + } + } + if let Some(globals @ Object::Ref(_)) = d.get(b"JBIG2Globals") { + made.set(b"JBIG2Globals".to_vec(), globals.clone()); + } + Ok(Object::Dict(made)) + } + let read = move |run: &mut Self, doc: &Document, o: &Object| match o { + Object::Array(a) => { + let mut entries = Vec::new(); + for e in a.iter().take(filters) { + entries.push((*run.follow(doc, e, How::Entry, entry)?).clone()); + } + Ok(Object::Array(entries)) + } + other => entry(run, doc, other), + }; + Ok((*self.follow(doc, v, How::Parms(filters), read)?).clone()) + } + + /// Whether `s`'s data is in another file, which a reader may draw instead of what is here: a `/F` + /// that isn't null, through any references (a null entry is as if absent, and a reference to no + /// object is null, ISO 32000-2 §7.3.7 and §7.3.10). A ring of references counts as a file. + fn elsewhere(&mut self, doc: &Document, s: &Stream) -> Result { + let Some(f) = s.dict.get(b"F") else { return Ok(false) }; + let read = |_: &mut Self, _: &Document, o: &Object| Ok(Object::Bool(!matches!(o, Object::Null))); + Ok(matches!(*self.follow(doc, f, How::Present, read)?, Object::Bool(true))) + } + + /// Whether `s`'s colour space is known to have more than one component, through any references: + /// a JBIG2 image is drawn as one whatever its dictionary says, so such an image read as one bit + /// would show otherwise. An unknown colour space isn't known to have more. + fn several_components(&mut self, doc: &Document, s: &Stream) -> Result { + let Some(cs) = s.dict.get(b"ColorSpace") else { return Ok(false) }; + fn profile(_: &mut Run, _: &Document, o: &Object) -> Result { + // /N lives in the ICC profile stream, and shall be 1, 3 or 4 (ISO 32000-2 §8.6.5.5). + let n = match o { + Object::Stream(icc) => icc.dict.int(b"N").filter(|n| matches!(n, 1 | 3 | 4)), + _ => None, + }; + Ok(n.map_or(Object::Null, Object::Int)) + } + let read = |run: &mut Self, doc: &Document, o: &Object| { + let name = match o { + Object::Name(n) => n.as_slice(), + Object::Array(a) => match a.first().and_then(Object::as_name) { + Some(n) => n, + None => return Ok(Object::Bool(false)), + }, + _ => return Ok(Object::Bool(false)), + }; + Ok(Object::Bool(match name { + b"DeviceRGB" | b"RGB" | b"CalRGB" | b"Lab" | b"DeviceCMYK" | b"CMYK" => true, + b"ICCBased" => match o.as_array().and_then(|a| a.get(1)) { + Some(entry) => run.follow(doc, entry, How::Profile, profile)?.as_int().is_some_and(|n| n > 1), + None => false, + }, + _ => false, + })) + }; + Ok(matches!(*self.follow(doc, cs, How::Components, read)?, Object::Bool(true))) + } + + /// `s` with what `decoded_image` and `decoded_within` read as given made direct: its size, + /// filters and their parameters, each kept small, however often the file refers to a large object. + pub(crate) fn readable(&mut self, doc: &Document, s: &Stream) -> Result { + let mut readable = s.clone(); + for key in [b"Width".as_slice(), b"Height"] { + if let Some(v) = s.dict.get(key) { + let number = self.number(doc, v)?; + readable.dict.set(key.to_vec(), number); + } + } + if let Some(v) = s.dict.get(b"Filter") { + let filter = self.filter(doc, v)?; + readable.dict.set(b"Filter".to_vec(), filter); + } + if let Some(v) = s.dict.get(b"DecodeParms") { + let filters = match readable.dict.get(b"Filter") { + Some(Object::Array(a)) => a.len(), + Some(Object::Name(_)) => 1, + _ => 0, + }; + let parms = self.parms(doc, v, filters)?; + readable.dict.set(b"DecodeParms".to_vec(), parms); + } + Ok(readable) + } + + /// Counts what was decoded: a unit, and one more for each whole 4 KiB. + pub(crate) fn decoded(&mut self, bytes: usize) -> Result<(), crate::RedactError> { + self.spend(bytes / 4096 + 1) + } +} + +/// What dictionary `num` holds, to know a copy of it by: a file put together from pages split apart +/// has one per page, each byte for byte the others. +/// +/// Decoded within `images`, the run's image allowance, which pays for what every filter stage reads +/// and makes: what the last one makes says nothing of the work (a predictor over what three Flates +/// inflate, which hex then skips as white space, ends at nothing). Past it the run is refused +/// ([`RedactError::ImagesPastAllowance`](crate::RedactError::ImagesPastAllowance)). +pub(crate) fn content(run: &mut Run, doc: &Document, num: u32, images: &mut ImageBudget) -> Result, crate::RedactError> { + let Ok(object) = doc.try_get(num) else { return Ok(None) }; + let Object::Stream(s) = &*object else { return Ok(None) }; + let readable = run.readable(doc, s)?; + let refused = images.refused(); + let data = images.decode_filters(doc, &readable, MAX_JBIG2_GLOBALS); + if data.is_err() && images.refused() > refused { + return Err(crate::RedactError::ImagesPastAllowance); + } + let mut hash = DefaultHasher::new(); + match &data { + Ok(data) => { + run.decoded(data.len())?; + run.sizes.insert(num, data.len()); + data.hash(&mut hash); + } + // What it took to find out it can't be read: as much as it was allowed to decode. + Err(_) => { + run.decoded(MAX_JBIG2_GLOBALS)?; + run.sizes.insert(num, MAX_JBIG2_GLOBALS); + s.raw[..].hash(&mut hash); + } + } + Ok(Some(hash.finish())) +} + +/// JBIG2 stream `s` stored alone, as a cleared copy is: what it shows unchanged and no dictionary +/// referred to. `None` for a stream that isn't JBIG2, whose decoder took no dictionary and whose +/// samples another filter may give otherwise; for one past the size of an image redaction clears, +/// or known to have more than one component, which JBIG2 is drawn as one of whatever the dictionary +/// says; for one whose data is in another file (`/F`), which readers draw instead of what is here; +/// and for one that can't be decoded. +pub(crate) fn without_dictionary(run: &mut Run, doc: &Document, s: &Stream, images: &mut ImageBudget) -> Result, crate::RedactError> { + if run.elsewhere(doc, s)? { + return Ok(None); + } + let readable = run.readable(doc, s)?; + let jbig2 = match readable.dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().any(|f| f.as_name() == Some(b"JBIG2Decode".as_slice())), + Some(f) => f.as_name() == Some(b"JBIG2Decode".as_slice()), + None => false, + }; + if !jbig2 || run.several_components(doc, s)? { + return Ok(None); + } + let side = |key: &[u8]| readable.dict.int(key).and_then(|v| usize::try_from(v).ok()); + let (Some(w), Some(h)) = (side(b"Width"), side(b"Height")) else { return Ok(None) }; + if (w as u64).saturating_mul(h as u64) > crate::image::MAX_PIXELS { + return Ok(None); + } + // As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one + // of the raster and a byte a row. + let Some(allowance) = + w.div_ceil(8).checked_mul(h).and_then(|n| n.checked_add(h)).and_then(|n| n.checked_mul(3)).and_then(|n| n.checked_add(4096)) + else { + return Ok(None); + }; + // Decoding it undoes the filters of each dictionary it names again when cos doesn't hold their + // bytes: charged as much as each was found to decode to. + let mut named = std::collections::BTreeSet::new(); + for (list, _) in run.names_of(doc, s)? { + named.extend(list.iter().copied()); + } + let undone = named.iter().filter_map(|n| run.sizes.get(n)).fold(0usize, |sum, size| sum.saturating_add(*size)); + if undone > 0 { + run.decoded(undone)?; + } + // Through the run's image allowance, which the decoder's own work, the dictionary's filters + // when they are undone again and the pixels are paid from. Past it the run is refused, as one + // clearing too many images is. + let refused = images.refused(); + let data = match images.decode(doc, &readable, allowance) { + Ok(data) => data, + Err(_) if images.refused() > refused => return Err(crate::RedactError::ImagesPastAllowance), + Err(_) => { + // What it took to find out it can't be read: as much as it was allowed to decode. + run.decoded(allowance)?; + return Ok(None); + } + }; + run.decoded(data.len())?; + let mut dict = readable.dict.clone(); + // `/FFilter` and `/FDecodeParms` too: with no data in another file they mean nothing, but + // `names_of` reads them. + for key in [b"Length".as_slice(), b"DecodeParms", b"DP", b"FFilter", b"FDecodeParms"] { + dict.remove(key); + } + dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + Ok(Some(crate::image::smallest(dict, &data, w, h, true, true))) +} + +/// Whether dictionary `n` is one that left (`left`), or a copy of one (`contents`); what each +/// object holds is found once a run (`read`). +fn matches_left( + run: &mut Run, + doc: &Document, + n: u32, + left: &HashSet, + contents: &HashSet, + read: &mut HashMap>, + images: &mut ImageBudget, +) -> Result { + if left.contains(&n) { + return Ok(true); + } + let held = match read.get(&n) { + Some(held) => *held, + None => { + let held = content(run, doc, n, images)?; + read.insert(n, held); + held + } + }; + Ok(held.is_some_and(|c| contents.contains(&c))) +} + +/// Takes the `/JBIG2Globals` dictionaries of what left the document since `before` ([`kept`]) out of +/// the file: everything a full save would keep that names one, or a copy of one, is stored again +/// without it, in place, so what refers to that stream stays as it is. Gives the number stored +/// again. A copy is found byte for byte; another dictionary that happens to hold the same shapes isn't. +/// +/// What left, not what a run says it removed: an image goes with a stamp, a widget, a Type 3 glyph, +/// an `/Alternates` entry or a form that can't be read, none of which it was drawn through directly. +/// An image another page still draws hasn't left: it keeps its pixels, so its dictionary keeps nothing +/// more. +/// +/// What it reads and decodes is counted against [`RUN_BUDGET`] for the whole run, and each +/// dictionary and image is decoded within `images`, the run's image allowance. +/// +/// Errors: [`RedactError::SharedJbig2Globals`](crate::RedactError::SharedJbig2Globals) when a dictionary would +/// still be kept, as what names it can't be stored without it (counted) or something else refers to +/// it (0 when that is all); [`RedactError::SharedJbig2Budget`](crate::RedactError::SharedJbig2Budget) when +/// finding that would spend more than the budget; [`RedactError::ImagesPastAllowance`](crate::RedactError::ImagesPastAllowance) +/// when decoding the dictionaries and images would spend more than `images` has left. Such an image may still be drawn, on a page no mark +/// reached, so it isn't removed, and `doc` is part changed by then: a caller works on a copy it can drop. +pub(crate) fn unshare(doc: &mut Document, before: &HashSet, images: &mut ImageBudget) -> Result { + let now = kept(doc)?; + let mut run = Run::new(RUN_BUDGET); + let mut dictionaries: HashSet = HashSet::new(); + // A list the file refers to is added once, however many streams share it. + let mut added: HashSet = HashSet::new(); + for &num in before.difference(&now) { + if let Ok(object) = doc.try_get(num) + && let Object::Stream(s) = &*object + { + for (list, key) in run.names_of(doc, s)? { + if key.is_none_or(|k| added.insert(k)) { + dictionaries.extend(list.iter()); + } + } + } + } + if dictionaries.is_empty() { + return Ok(0); + } + let mut contents: HashSet = HashSet::new(); + for &n in &dictionaries { + if let Some(c) = content(&mut run, doc, n, images)? { + contents.insert(c); + } + } + let mut read: HashMap> = HashMap::new(); + // Whether a name matches doesn't depend on order: it is a dictionary that left, or a copy of one + // (what is added below is only ever such a copy). So what a shared list matches is found once. + let left = dictionaries.clone(); + let mut matches: HashMap> = HashMap::new(); + added.clear(); + let mut users = Vec::new(); + for (r, object) in doc.scan_objects() { + let Object::Stream(s) = &*object else { continue }; + if !now.contains(&r.num) { + continue; + } + // Only what matched has to leave, a copy found here included. Another dictionary this also + // names was shared with nothing that left: what else uses it may keep it, whichever comes + // first in the file. + let mut uses = false; + for (list, key) in run.names_of(doc, s)? { + let matched = match key.and_then(|k| matches.get(&k)) { + Some(hit) => hit.clone(), + None => { + let mut found = Vec::new(); + for &n in list.iter() { + if matches_left(&mut run, doc, n, &left, &contents, &mut read, images)? { + found.push(n); + } + } + let found: Rc<[u32]> = found.into(); + if let Some(k) = key { + run.spend(found.len())?; + matches.insert(k, found.clone()); + } + found + } + }; + if !matched.is_empty() { + uses = true; + if key.is_none_or(|k| added.insert(k)) { + dictionaries.extend(matched.iter()); + } + } + } + if uses { + users.push(r); + } + } + let (mut stored_again, mut stuck) = (0, 0); + for &r in &users { + let object = doc.get(r); + let Object::Stream(s) = &*object else { continue }; + match without_dictionary(&mut run, doc, s, images)? { + Some(stored) => { + // In place, with both generations of a slot whose cross-reference row and object + // header disagree, which cos reads either way (#1143): `set` would keep only `r`'s. + doc.set_existing(r, Object::Stream(stored))?; + stored_again += 1; + } + // A dictionary that names itself isn't something that uses one. + None if dictionaries.contains(&r.num) => {} + None => stuck += 1, + } + } + let after = kept(doc)?; + if dictionaries.iter().any(|n| after.contains(n)) { + return Err(crate::RedactError::SharedJbig2Globals(stuck)); + } + // What couldn't be stored has left with the dictionaries, a dictionary that names itself say: + // it isn't counted. + Ok(stored_again) +} diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index a99e179895..8578ca32cd 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -11,11 +11,12 @@ use pdfcraft_content::{Matrix, overlaps}; use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; /// Most pixels an image may have to be cleared in place (larger ones are removed), or to be stored -/// again without shared `/JBIG2Globals` (see [`without_globals`]). +/// again without shared `/JBIG2Globals` (see [`crate::globals::without_dictionary`]). pub(crate) const MAX_PIXELS: u64 = pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS; -fn components(doc: &Document, s: &Stream) -> Option { - let cs = doc.resolve(s.dict.get(b"ColorSpace")?); +/// The components of image `s`'s colour space, through any references (`None` when unknown). +pub(crate) fn components(doc: &Document, s: &Stream) -> Option { + let cs = crate::globals::followed(doc, s.dict.get(b"ColorSpace")?); let cs = &*cs; let name = match cs { Object::Name(n) => n.as_slice(), @@ -28,7 +29,7 @@ fn components(doc: &Document, s: &Stream) -> Option { b"DeviceCMYK" | b"CMYK" => 4, b"ICCBased" => { // /N lives in the ICC profile stream, and shall be 1, 3 or 4 (ISO 32000-2 §8.6.5.5). - match &*doc.resolve(cs.as_array()?.get(1)?) { + match &*crate::globals::followed(doc, cs.as_array()?.get(1)?) { Object::Stream(icc) => usize::try_from(icc.dict.int(b"N")?).ok().filter(|n| matches!(n, 1 | 3 | 4))?, _ => return None, } @@ -126,9 +127,9 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] data.truncate(bytes); // Image masks: a sample of 0 paints unless /Decode is [1 0]; cleared samples must not paint. let clear_bit = if mask { - // The array, and its first number, given by reference too. - let decode = s.dict.get(b"Decode").map(|d| doc.resolve(d)); - let first = decode.as_deref().and_then(Object::as_array).and_then(|d| d.first()).map(|v| doc.resolve(v)); + // The array, and its first number, given by reference too, through any references. + let decode = s.dict.get(b"Decode").map(|d| crate::globals::followed(doc, d)); + let first = decode.as_deref().and_then(Object::as_array).and_then(|d| d.first()).map(|v| crate::globals::followed(doc, v)); let inverted = first.as_deref().and_then(Object::as_f64) == Some(1.0); !inverted } else { @@ -170,19 +171,31 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] return Some(None); } let mut dict = s.dict.clone(); - dict.remove(b"Length"); - dict.remove(b"DecodeParms"); - dict.remove(b"Alternates"); + for key in [b"Length".as_slice(), b"DecodeParms", b"DP", b"Alternates"] { + dict.remove(key); + } + if !data_elsewhere(doc, s) { + // Parameters for data in another file mean nothing without `/F`, but may name a dictionary. + dict.remove(b"FFilter"); + dict.remove(b"FDecodeParms"); + } Some(Some(smallest(dict, &data, w, h, ncomp == 1 && bpc == 1, is_jbig2))) } +/// Whether `s`'s data is in another file, which a reader may draw instead of what is here: a `/F` +/// that isn't null, through any references (a null entry is as if absent, and a reference to no +/// object is null, ISO 32000-2 §7.3.7 and §7.3.10). A ring of references counts as a file. +pub(crate) fn data_elsewhere(doc: &Document, s: &Stream) -> bool { + s.dict.get(b"F").is_some_and(|f| !matches!(&*crate::globals::followed(doc, f), Object::Null)) +} + /// The cleared samples `data` of a `w` × `h` image as a stream with `dict`: Flate, or, for one bit /// of one component (`bilevel`, a scan as a rule, which Flate stores several times larger), CCITT /// Group 4, or JBIG2 when the image was JBIG2 (`jbig2`; JBIG2 is PDF 1.4, which such a file already /// is), whichever is smallest as stored, dictionary included (a tiny image can lose by Group 4's /// parameters). Both decode to `data`, cleared before it is coded, so nothing of the original's /// code is carried over; Group 4 or JBIG2 that can't code the rows (too wide) is left out. -fn smallest(dict: Dict, data: &[u8], w: usize, h: usize, bilevel: bool, jbig2: bool) -> Stream { +pub(crate) fn smallest(dict: Dict, data: &[u8], w: usize, h: usize, bilevel: bool, jbig2: bool) -> Stream { let flate = Stream::flate(dict.clone(), data); let Some((columns, rows)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()).filter(|_| bilevel) else { return flate }; let g4 = pdfcraft_filters::encode_g4(data, columns, rows).ok().map(|coded| { @@ -209,38 +222,3 @@ fn stored_len(s: &Stream) -> usize { pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut dict); dict.len().saturating_add(s.raw.len()) } - -/// JBIG2 image `s` stored again without its `/JBIG2Globals`: decoded through them, as it is drawn, -/// and written as the same samples, Flate, Group 4 or JBIG2 coded alone, whichever is smallest -/// (`smallest`), its other entries kept. `None` when it can't be decoded for certain (too large, -/// past what is left of `budget`, data that doesn't decode, a JBIG2 page of another size than the -/// image, or not a one-bit image of one component). Redaction does this to every image that -/// shares a redacted image's globals, since their symbol dictionary holds the shapes of marks drawn -/// under the area too. -pub(crate) fn without_globals(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Option { - let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?); - if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS { - return None; - } - let (w, h) = (w as usize, h as usize); - let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true))); - // JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7). - if !mask && (components(doc, s)?, s.dict.int(b"BitsPerComponent")?) != (1, 1) { - return None; - } - let bytes = w.div_ceil(8).checked_mul(h)?; - // As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one - // of the raster and a byte a row. - let allowance = bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?; - let mut data = budget.decode(doc, s, allowance).ok()?; - if data.len() < bytes { - return None; - } - data.truncate(bytes); - let mut dict = s.dict.clone(); - dict.remove(b"Length"); - // The globals are named in the parameters; the copy is coded alone, as `clear`'s are. - dict.remove(b"DecodeParms"); - dict.remove(b"Alternates"); - Some(smallest(dict, &data, w, h, true, true)) -} diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 40f07f1f42..1e69cb32e2 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -228,6 +228,12 @@ impl<'a> Scope<'a> { } } + /// What is left of the page's allowance once the content is walked, for the cleanups after it + /// that read the forms drawing through the page's resources ([`prune`]) to go on spending. + pub(crate) fn into_budget(self) -> DecodeBudget { + self.budget + } + /// Keep at most `room` bytes of removed text: what earlier pages left of the document's /// [`MAX_REMOVED_BYTES`]. pub fn with_removed_room(mut self, room: usize) -> Self { @@ -1096,7 +1102,9 @@ fn xobject( out.doomed.push(r); return Some(None); } - // Strict, like page content: a Form read only in part counts as undecodable. + // Strict, like page content: a Form read only in part counts as undecodable. With its + // `/DecodeParms` given by reference too, as the cleanups after the walk read it: read + // without them, it looks empty and is left as it was, drawing whatever lies under a mark. let data = if scope.budget.enter_form() { scope.budget.decode_strict(doc, s) } else { @@ -1173,11 +1181,11 @@ fn xobject( let mut cleaned_res = res.clone(); // Install rewritten nested forms before finding live property names: a new form may // still inherit its caller's resources, and its new Do name must resolve here. - if !inner.xobjects.is_empty() || !inner.replaced.is_empty() { + // `prune_form_resources` below then drops the XObjects this form no longer draws, the + // ones it replaced among them, keeping those a form drawing through these resources + // still draws. + if !inner.xobjects.is_empty() { let mut xo = res_dict(doc, &cleaned_res, b"XObject"); - if let Some(data) = &new_data { - prune(&mut xo, &inner.replaced, std::slice::from_ref(data)); - } for (name, reference) in &inner.xobjects { xo.set(name.clone(), Object::Ref(*reference)); } @@ -1185,20 +1193,20 @@ fn xobject( } if let Some(data) = &new_data { let content = std::slice::from_ref(data); - match prune_properties(doc, &mut cleaned_res, content) { + match prune_properties(doc, &mut scope.budget, &mut cleaned_res, content) { Ok(retired) => out.retired_properties.extend(retired), Err(error) => { out.error = Some(error); return None; } } - if let Err(error) = prune_form_resources(doc, &mut cleaned_res, content) { + if let Err(error) = prune_form_resources(doc, &mut scope.budget, &mut cleaned_res, content) { out.error = Some(error); return None; } // No page: annotation appearances draw through a page's resources, never through a // form's. The form's own soft masks are still counted. - if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content, None) { + if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut scope.budget, &mut cleaned_res, content, None) { out.error = Some(error); return None; } diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 2bfae6391f..39089601ec 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -3,49 +3,81 @@ use super::{ is_vertical, num, overlaps, page_streams, res_dict, }; -/// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, -/// so a full-rewrite save stops reaching the original image or form it named. A name that some -/// surviving `Do` still draws (the same image shown again outside a mark) is kept. -pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec], content: &[Vec]) { +/// Drop every `replaced` name of the XObject resources `xo` that the rewritten `content` no +/// longer draws, so a full-rewrite save stops reaching the original image or form it named. A +/// name still drawn is kept: by a surviving `Do` (the same image shown again outside a mark), or +/// by a form without /Resources of its own, which draws through `xo` too. Such forms are read +/// within `budget`, what is left of the page's allowance ([`resource_ops`]). Fails, as the cleanup +/// of the fonts and properties these forms draw with does, when that can't be told: such a form +/// doesn't decode (an image codec or a /Filter it can't run, say) or runs past `budget`, the walk +/// passes 2,000,000 operators, or the parser can't read all of such a form or of `content`. +/// Corrupt data in such a form is read, tolerantly, up to the damage, which may be nothing. +pub(crate) fn prune(doc: &Document, budget: &mut DecodeBudget, xo: &mut Dict, replaced: &[Vec], content: &[Vec]) -> Result<(), CosError> { if replaced.is_empty() { - return; + return Ok(()); } - let joined = Pieces::join(content); - let ops = joined.parse().ops; - let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.last_name() == Some(n)); + let res = Dict::from_iter([(b"XObject".to_vec(), Object::Dict(xo.clone()))]); + let ops = resource_ops(doc, budget, &res, content)?; + let drawn: HashSet<&[u8]> = ops.iter().filter(|op| op.is("Do")).filter_map(Op::last_name).collect(); for n in replaced { - if !still_drawn(n) { + if !drawn.contains(n.as_slice()) { xo.remove(n); } } + Ok(()) } /// The XObject names of the resource dictionary `xo` that `content` draws: directly, or through /// a form that has no /Resources of its own (such a form draws through its caller's). Each such -/// form is read once, however many names are asked about. The flag says whether some of what -/// `content` draws could not be read (a form that will not decode, or nesting past the limit), so -/// a name missing from the set may still be drawn. +/// form is read once, however many names are asked about, with its `/DecodeParms` given by +/// reference. The flag says whether some of what `content` draws could not be read (a form that +/// will not decode, or nesting past the limit), so a name missing from the set may still be drawn. +/// Operators are read one at a time, since only the `Do`s matter: no stream's are held whole, and +/// each form is decoded only in its turn. fn names_content_draws(doc: &Document, content: &[Vec], xo: &Dict) -> (HashSet>, bool) { let mut drawn = HashSet::new(); let mut seen = HashSet::new(); - let mut pending = vec![(Pieces::join(content).parse().ops, 0)]; + let joined = Pieces::join(content); + // What is still to read, each with its depth: `content` itself (`None`), then the forms it draws. + let mut pending: Vec<(Option, usize)> = vec![(None, 0)]; // A form that will not decode makes the answer uncertain, but it does not stop the forms // already queued from being read: a name drawn only inside one of those need not be unknown. let mut unknown = false; - while let Some((ops, depth)) = pending.pop() { - for n in ops.iter().filter(|op| op.is("Do")).filter_map(Op::last_name) { + while let Some((form, depth)) = pending.pop() { + let data = match form { + None => std::borrow::Cow::Borrowed(joined.data()), + Some(r) => { + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + match doc.with_direct_parms(s).decoded() { + Ok(data) => std::borrow::Cow::Owned(data), + Err(_) => { + unknown = true; + continue; + } + } + } + }; + for op in pdfcraft_content::ops(&data).filter(|op| op.is("Do")) { + let Some(n) = op.last_name() else { continue }; if !drawn.insert(n.to_vec()) { continue; } let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue }; let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; - if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) { + // A /Resources that isn't a dictionary (null, or a missing object) is no resources of + // its own, as for `resource_ops` and the interpreter. + if s.dict.name(b"Subtype") != Some(b"Form") + || s.dict.get(b"Resources").is_some_and(|r| doc.resolve(r).as_dict().is_some()) + || !seen.insert(r) + { continue; } - match s.decoded() { - Ok(data) if depth < MAX_DEPTH => pending.push((pdfcraft_content::parse(&data).ops, depth + 1)), - _ => unknown = true, + if depth < MAX_DEPTH { + pending.push((Some(r), depth + 1)); + } else { + unknown = true; } } } @@ -322,7 +354,8 @@ fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], c } let mut used = HashSet::new(); for content in contents { - for op in resource_ops(doc, res, content)? { + // Each is one page's content or one form's, with the allowance of a page. + for op in resource_ops(doc, &mut DecodeBudget::page(), res, content)? { if matches!(op.op.as_slice(), b"BDC" | b"DP") && let Some(name) = op.name(1) { @@ -383,7 +416,7 @@ fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet Op Some(ops) } -pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> Result, CosError> { - let parsed = Pieces::join(content).parse(); - if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); +/// The operators of `content`, and of every form without /Resources of its own that it draws +/// through `res`, nested ones too: what draws with these resources. Each such form is read once, +/// with its `/DecodeParms` given by reference, its decoded bytes charged to `budget`, so the +/// cleanups of one page that walk the same forms spend one allowance between them. An error +/// when that can't all be read: the parser can't read all of `content` or of such a form, the walk +/// passes 2,000,000 operators, or such a form doesn't decode or runs past `budget`. Operators are +/// read one at a time, so the walk stops at one past the limit rather than holding them all. +pub(crate) fn resource_ops(doc: &Document, budget: &mut DecodeBudget, res: &Dict, content: &[Vec]) -> Result, CosError> { + const MAX_OPS: usize = 2_000_000; + let joined = Pieces::join(content); + let mut read = pdfcraft_content::ops(joined.data()); + let mut ops: Vec = read.by_ref().take(MAX_OPS + 1).collect(); + // Under the limit the reader ran to the end, so `skipped` counts all of it. + if read.skipped() > 0 || ops.len() > MAX_OPS { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully read the content that uses these resources".into() }); } - let mut ops = parsed.ops; let xobjects = res_dict(doc, res, b"XObject"); let mut seen = HashSet::new(); let mut cursor = 0; @@ -455,24 +498,26 @@ pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> R && s.dict.name(b"Subtype") == Some(b"Form") && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) { - let parsed = pdfcraft_content::parse(&s.decoded()?); - if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { + let data = budget.decode(&doc.with_direct_parms(s))?; + let mut read = pdfcraft_content::ops(&data); + ops.extend(read.by_ref().take((MAX_OPS + 1).saturating_sub(ops.len()))); + // `seen` gains at most one form for each `Do` in `ops`, so this bounds it too. + if ops.len() > MAX_OPS || read.skipped() > 0 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); } - ops.extend(parsed.ops); } cursor += 1; } Ok(ops) } -pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result, CosError> { +pub(crate) fn prune_properties(doc: &Document, budget: &mut DecodeBudget, res: &mut Dict, content: &[Vec]) -> Result, CosError> { let mut properties = res_dict(doc, res, b"Properties"); if properties.is_empty() { return Ok(Vec::new()); } let mut retired = Vec::new(); - let ops = resource_ops(doc, res, content)?; + let ops = resource_ops(doc, budget, res, content)?; let names: Vec> = properties.iter().map(|(n, _)| n.to_vec()).collect(); for name in names { if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name)) @@ -489,8 +534,8 @@ pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { - let ops = resource_ops(doc, res, content)?; +pub(super) fn prune_form_resources(doc: &Document, budget: &mut DecodeBudget, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { + let ops = resource_ops(doc, budget, res, content)?; let mut xo = res_dict(doc, res, b"XObject"); let names: Vec> = xo.iter().map(|(n, _)| n.to_vec()).collect(); for name in names { diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 8846b7a501..7c1a65f55a 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -13,7 +13,9 @@ //! under a region the whole operation fails (callers keep the previous document: fail-closed). //! Finally, document metadata (`/Info` entries, XMP) that repeats the removed text is removed. //! Places it leaves as they are (bookmark titles, attachments, remaining comments, other XMP) -//! are only counted when they still hold that text ([`Report::still_holding`]). +//! are only counted when they still hold that text ([`Report::still_holding`]). Last, a shared +//! JBIG2 symbol dictionary (`/JBIG2Globals`) that anything gone by then used leaves the file too: +//! what still names it is stored again without it, or the redaction is refused. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -23,6 +25,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; pub mod codes; mod elsewhere; +mod globals; mod identification; mod image; mod interp; @@ -96,12 +99,22 @@ pub enum RedactError { /// take what no mark covers with it, unreported. #[error("the images under these marks are more than one redaction can decode, so nothing was changed; redact fewer pages at a time")] ImagesPastAllowance, - /// A JBIG2 image shares its `/JBIG2Globals` with a redacted one and can't be stored again - /// without them, so the shapes they hold, those of marks under the area included, would stay. - #[error( - "{0} JBIG2 image(s) share their symbol data with a redacted image and can't be decoded to be stored without it, so that data, which can show what is under the marks, would stay in the file; nothing was redacted" - )] + /// `/JBIG2Globals` used by something removed would stay: something that names them can't be + /// stored again without them, or something else refers to them. Either way the shapes they + /// hold, those of marks under the area included, would stay. Counts what can't be stored without + /// them: 0 when only something else keeps them. + #[error("{}", shared_jbig2_text(*.0))] SharedJbig2Globals(usize), + /// Finding what shares JBIG2 symbol data with what is being removed would read more of the file + /// than one run allows (`globals::RUN_BUDGET`): its images and parameters refer to the same + /// objects in more ways than are checked, or hold more than is decoded. Removing hidden + /// information says so too when the dictionaries it reads to know copies, or the images it + /// stores again, would decode more than its image allowance, which no choice of pages makes + /// smaller. + #[error( + "finding and storing again what shares JBIG2 symbol data with something being removed would read more of this file than one run allows, so it can't be shown that the data leaves with it; this was refused" + )] + SharedJbig2Budget, #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] @@ -110,6 +123,15 @@ pub enum RedactError { Cos(#[from] pdfcraft_cos::CosError), } +/// [`RedactError::SharedJbig2Globals`]'s text, which says what keeps the data. +fn shared_jbig2_text(stuck: usize) -> String { + let why = match stuck { + 0 => "something else in the file refers to JBIG2 symbol data shared with something being removed".to_string(), + n => format!("{n} item(s) share JBIG2 symbol data with something being removed and can't be stored without it"), + }; + format!("{why}, so that data, which can show what was removed, would stay in the file; this was refused") +} + /// A redaction mark on a page. #[derive(Clone, Debug, PartialEq)] pub struct Mark { @@ -167,7 +189,7 @@ pub struct Report { pub images_removed: usize, pub images_cleared: usize, /// JBIG2 images, anywhere in the document, stored again (as a cleared copy is) without the - /// `/JBIG2Globals` they shared with a redacted image, so those globals leave the file. + /// `/JBIG2Globals` they shared with something this removed, so those globals leave the file. pub images_restored: usize, pub paths_removed: usize, pub paths_clipped: usize, @@ -484,6 +506,7 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co pdfcraft_fonts::undrawable_message(&format!("the overlay text of a redaction mark on page {}", m.page + 1), c) ))); } + let kept_before = globals::kept(doc)?; let mut report = Report { marks: chosen.len(), ..Report::default() }; // `marks` visits pages in order. Keep slices of that order for appearance generation, // content rewriting and verification instead of scanning all marks for every page. @@ -645,7 +668,6 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co interp::unlink_doomed(doc, &doomed_xobjects, &retired_properties)?; // Likewise a pruned pattern: a /Pages node above the page can still name its cell. interp::unlink_pruned_patterns(doc, &pruned_patterns)?; - report.images_restored = restore_shared_jbig2(doc, &doomed_xobjects, &mut images)?; // 5. Verify: nothing readable may remain under a region. let mut residue = 0; @@ -694,6 +716,10 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co let scrubbed = metadata::scrub(doc, &removed_text, !removed_cut)?; report.metadata = scrubbed.removed; report.identification = scrubbed.identification; + // Last of what leaves: an image that left with any of the above, the metadata removed just + // now included, is a user of a dictionary too. Storing images again changes no page's content + // or pattern cells, so the checks above hold for what follows. + report.images_restored = globals::unshare(doc, &kept_before, &mut images)?; // What it doesn't scrub is counted, never changed. report.still_holding = elsewhere::find(doc, &removed_text, !removed_cut); // The previous revision still holds the removed content: the next save must rewrite, and the @@ -702,76 +728,6 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co Ok(report) } -/// Most XObjects followed down from the removed and replaced ones to find their JBIG2 images. -const MAX_JBIG2_WALK: usize = 100_000; - -/// The `/JBIG2Globals` of a redacted JBIG2 image hold the symbol dictionary of every image that -/// shares them (`jbig2enc -s` and most scanners give one to all the pages of a scan): the bitmaps -/// of marks drawn only under the area too. The image itself is replaced or removed, but the globals -/// would stay for as long as another image uses them. So every image that uses the globals of a -/// removed or replaced image (`doomed`), or of one drawn in a removed or replaced form, is decoded -/// and stored again in place without them, as a cleared copy is, and a full save then leaves them -/// out. An image that can't be stored again refuses the redaction (nothing changes, as `apply` -/// works on a copy), but for a removed or replaced one, which is either unlinked or still drawn -/// whole on another page. They are decoded within what is left of the run's allowance (`images`); -/// past it the redaction is refused as one clearing too many images is. Returns how many images -/// that weren't removed or replaced were stored again. -fn restore_shared_jbig2(doc: &mut Document, doomed: &[ObjRef], images: &mut pdfcraft_cos::ImageBudget) -> Result { - let mut globals = HashSet::new(); - let mut seen = HashSet::new(); - let mut stack: Vec = doomed.to_vec(); - while let Some(r) = stack.pop() { - if !seen.insert(r) || seen.len() > MAX_JBIG2_WALK { - continue; - } - let object = doc.get(r); - let Object::Stream(s) = &*object else { continue }; - if let Some(g) = doc.jbig2_globals_ref(s) { - globals.insert(g); - } - // A form's images, and its forms' (an image's /SMask is a stream of its own, found below). - let resources = s.dict.get(b"Resources").map(|r| doc.resolve(r)); - let xobjects = resources.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x)); - if let Some(x) = xobjects.as_deref().and_then(Object::as_dict) { - stack.extend(x.iter().filter_map(|(_, v)| v.as_ref())); - } - if let Some(m) = s.dict.get(b"SMask").and_then(Object::as_ref) { - stack.push(m); - } - } - if globals.is_empty() { - return Ok(0); - } - let sharing: Vec = doc - .scan_objects() - .filter(|(_, o)| matches!(&**o, Object::Stream(s) if doc.jbig2_globals_ref(s).is_some_and(|g| globals.contains(&g)))) - .map(|(r, _)| r) - .collect(); - let doomed: HashSet = doomed.iter().copied().collect(); - let (mut restored, mut stuck, mut past_allowance) = (0, 0, false); - for r in sharing { - let object = doc.get(r); - let Object::Stream(s) = &*object else { continue }; - let refused = images.refused(); - match image::without_globals(doc, s, images) { - Some(new) => { - doc.set(r, Object::Stream(new)); - restored += usize::from(!doomed.contains(&r)); - } - None if doomed.contains(&r) => {} - None if images.refused() > refused => past_allowance = true, - None => stuck += 1, - } - } - if past_allowance { - return Err(RedactError::ImagesPastAllowance); - } - if stuck > 0 { - return Err(RedactError::SharedJbig2Globals(stuck)); - } - Ok(restored) -} - /// What applying the marks collects across pages. struct Applying { report: Report, @@ -812,14 +768,15 @@ fn redact_page_content( let mut budget = pdfcraft_cos::DecodeBudget::page(); let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let out = { + // What the walk leaves of the page's allowance, the cleanups below spend. + let (out, mut budget) = { let mut scope = Scope::new(rects, Mode::Apply, report, budget, images).with_removed_room(*removed_room); let out = process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY); scope.flush(); removed_text.append(&mut scope.removed_text); *removed_cut |= scope.removed_cut; *removed_room = scope.removed_room; - out + (out, scope.into_budget()) }; // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot // tell whether those glyphs lie under a mark. Rather than report success and possibly @@ -861,6 +818,11 @@ fn redact_page_content( let mut res = resources.clone(); if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + // The rewritten XObjects first, as for a rewritten form: one left without resources of + // its own draws through these, and `prune` follows it only under a name it can find. + for (n, r) in &out.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } // A full save keeps whatever is still referenced: unlink the originals this page no // longer draws so the removed image or form bytes cannot survive in the saved file. let page_content: Vec> = new_list @@ -875,10 +837,7 @@ fn redact_page_content( _ => Vec::new(), }) .collect(); - interp::prune(&mut xo, &out.replaced, &page_content); - for (n, r) in &out.xobjects { - xo.set(n.clone(), Object::Ref(*r)); - } + interp::prune(doc, &mut budget, &mut xo, &out.replaced, &page_content)?; res.set(b"XObject".to_vec(), Object::Dict(xo)); } let (_, cleaned_data) = page_streams( @@ -895,8 +854,8 @@ fn redact_page_content( // A Type 3 glyph without resources paints with patterns nobody tracked: keep them all. let painted = if out.keeps_all_patterns { &out.patterns_seen } else { &out.patterns_painted }; report.patterns_removed += interp::prune_patterns(doc, &mut res, &out.patterns_seen, painted, pruned_patterns); - retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); - type3::prune_type3_from_resources(doc, &mut res, &cleaned_data, Some(&page.dict))?; + retired_properties.extend(interp::prune_properties(doc, &mut budget, &mut res, &cleaned_data)?); + type3::prune_type3_from_resources(doc, &mut budget, &mut res, &cleaned_data, Some(&page.dict))?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 2734dcd7ee..0d4ee2e18a 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -662,7 +662,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let blocks = scope.layer_blocks; let glyphs = scope.report.glyphs; let retained_for_page = std::mem::take(&mut scope.retained_off_layers); - drop(scope); + // What the walk leaves of the page's allowance, the cleanups below spend. + let mut budget = scope.into_budget(); total += out.residue + blocks + glyphs; retained_off_layers.extend(retained_for_page); if !write { @@ -694,16 +695,18 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let mut res = resources.clone(); if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - // A full save keeps whatever is still referenced: unlink the originals this page no - // longer draws so the removed form or image bytes cannot survive in the saved file. - crate::interp::prune(&mut xo, &out.replaced, &page_content); + // The rewritten XObjects first, as on Apply Redactions' pages: `prune` follows a + // rewritten form left without resources of its own only under a name it can find. for (n, r) in &out.xobjects { xo.set(n.clone(), Object::Ref(*r)); } + // A full save keeps whatever is still referenced: unlink the originals this page no + // longer draws so the removed form or image bytes cannot survive in the saved file. + crate::interp::prune(doc, &mut budget, &mut xo, &out.replaced, &page_content)?; res.set(b"XObject".to_vec(), Object::Dict(xo)); } - retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?); - crate::type3::prune_type3_from_resources(doc, &mut res, &page_content, Some(&page.dict))?; + retired_properties.extend(crate::interp::prune_properties(doc, &mut budget, &mut res, &page_content)?); + crate::type3::prune_type3_from_resources(doc, &mut budget, &mut res, &page_content, Some(&page.dict))?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); @@ -811,6 +814,20 @@ pub fn scan_only(doc: &Document, which: &[Hidden]) -> Vec<(Hidden, usize)> { .collect() } +/// [`crate::globals::unshare`] for what left without marks: past `images` it is refused as finding +/// what shares the dictionaries is, since the error redaction gives (redact fewer pages at a time) +/// means nothing when no page was chosen. +pub(crate) fn unshare_unmarked( + doc: &mut Document, + before: &std::collections::HashSet, + images: &mut pdfcraft_cos::ImageBudget, +) -> Result { + crate::globals::unshare(doc, before, images).map_err(|e| match e { + RedactError::ImagesPastAllowance => RedactError::SharedJbig2Budget, + e => e, + }) +} + /// Take from the outline of catalog `cat` every action that does more than go to a place in this /// document (see [`outline_without_actions`]): bookmarks that stay keep where they go, not what /// they run. An outline too large or nested too deep to follow is refused, not left half cleaned: @@ -920,6 +937,9 @@ pub(crate) fn remove_counted( let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1); let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?; let mut done = Vec::new(); + // What a full save keeps before anything is removed: an image that leaves, by whatever route + // below, takes a shared JBIG2 dictionary with it (`unshare_unmarked`, last). + let kept_before = crate::globals::kept(doc)?; let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); // Pages' annotation lists change only for these; otherwise every page is left as it is. @@ -929,11 +949,7 @@ pub(crate) fn remove_counted( let listed_before = if annotations { listed_annotations(doc) } else { std::collections::HashSet::new() }; if fields { // Fields without appearances get one first, so their values stay visible. - for f in pdfcraft_forms::fields(doc) { - if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { - pdfcraft_forms::redraw_field(doc, &f.name)?; - } - } + pdfcraft_forms::redraw_fields(doc, |doc, f| f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))))?; let n = pdfcraft_model::pages(doc).len(); pdfcraft_edit::flatten(doc, &(0..n).collect::>(), false, true)?; doc.update_dict(root, |d| { @@ -1042,6 +1058,9 @@ pub(crate) fn remove_counted( if done.is_empty() && unlinked == 0 { return Err(RedactError::NothingToApply); } + // Images stored again without a dictionary, and the dictionaries read to know copies, are + // decoded within an allowance of their own. + unshare_unmarked(doc, &kept_before, &mut pdfcraft_cos::ImageBudget::new(doc))?; // The rewrite drops the revisions that held the removed content, so it is a different file from // its source: it gets a new identifier, whoever asked for the removal (the remove-hidden edit // and the optimizer's discard option call this function directly, not through [`sanitize`]). diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 031296a01e..46ac131450 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1368,6 +1368,25 @@ fn a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed() { vec![b"1".to_vec()], [0x00, 0xFF, 0xFF, 0xFF], ), + // Through a reference to a reference: the array, its first number, and a colour space. + ( + format!("{image} /ImageMask true /Decode 8 0 R /Filter /CCITTFaxDecode /DecodeParms {parms}"), + g4.clone(), + vec![b"9 0 R".to_vec(), b"[1 0]".to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + ( + format!("{image} /ImageMask true /Decode [8 0 R 0] /Filter /CCITTFaxDecode /DecodeParms {parms}"), + g4.clone(), + vec![b"9 0 R".to_vec(), b"1".to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + ( + format!("{image} /ColorSpace 8 0 R /Filter /CCITTFaxDecode /DecodeParms {parms}"), + g4.clone(), + vec![b"9 0 R".to_vec(), b"/DeviceGray".to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), ] { let mut objs = vec![stream(&dict, data)]; objs.extend(extra); @@ -2051,17 +2070,1078 @@ fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() { let mut doc = build(&image.replace(&format!("/Height {h}"), &format!("/Height {}", h + 1))); mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1)))); - // Storing the second page's image again decodes it, within what is left of the run's - // allowance: with nothing left the redaction is refused as one past the allowance, not - // reported as an image that can't be read. + // Reading the dictionary to know copies of it, and storing the second page's image again, + // decode within what is left of the run's allowance: past it the redaction is refused as one + // past the allowance, not reported as an image that can't be read. The first page's image + // leaves (its page no longer lists it). let mut doc = build(&image); - let first_image = [ObjRef::new(7, 0)]; - let refused = crate::restore_shared_jbig2(&mut doc, &first_image, &mut pdfcraft_cos::ImageBudget::with_work(0)); + let before = crate::globals::kept(&doc).unwrap(); + let first = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(first, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + let refused = crate::globals::unshare(&mut doc.clone(), &before, &mut pdfcraft_cos::ImageBudget::with_work(0)); assert!(matches!(refused, Err(RedactError::ImagesPastAllowance)), "{refused:?}"); - let restored = crate::restore_shared_jbig2(&mut doc, &first_image, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)); + // Enough to read the dictionary, and nothing left for the image. + let mut reading = pdfcraft_cos::ImageBudget::with_work(u64::MAX); + crate::globals::content(&mut crate::globals::Run::new(crate::globals::RUN_BUDGET), &doc, 9, &mut reading).unwrap(); + let read = u64::MAX - reading.work_left(); + let refused = crate::globals::unshare(&mut doc.clone(), &before, &mut pdfcraft_cos::ImageBudget::with_work(read)); + assert!(matches!(refused, Err(RedactError::ImagesPastAllowance)), "the image: {refused:?}"); + // Removing hidden information chose no pages, so fewer of them is no way out: it is refused as + // finding what shares the dictionary is. + let refused = crate::sanitize::unshare_unmarked(&mut doc.clone(), &before, &mut pdfcraft_cos::ImageBudget::with_work(0)); + assert!(matches!(refused, Err(RedactError::SharedJbig2Budget)), "{refused:?}"); + let restored = crate::globals::unshare(&mut doc, &before, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)); assert_eq!(restored.ok(), Some(1)); } +/// A scan's parts when it keeps its symbols in `/JBIG2Globals`: the dictionary, moved out as segment +/// data belonging to no page, and the page information and regions that are left. +struct SharedScan { + bits: Vec, + symbols: Vec, + page: Vec, +} + +fn shared_scan() -> SharedScan { + const LETTERS: [[&str; 7]; 3] = [ + ["#####", "#....", "####.", "#....", "#....", "#....", "#####"], + [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."], + ["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"], + ]; + let (w, h) = (400usize, 40usize); + let stride = w / 8; + let mut bits = vec![0xFFu8; stride * h]; + for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() { + for (k, x) in (3..w - 6).step_by(7).enumerate() { + for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + let coded = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + // The end of the segment at `at`, and where its page association is (7.2.2 to 7.2.7). + let end = |at: usize| { + let page = at + 6 + usize::from(coded[at + 5] >> 5); + let length = u32::from_be_bytes(coded[page + 1..page + 5].try_into().unwrap()); + (page + 5 + length as usize, page) + }; + let (dictionary, _) = end(0); + assert_eq!(coded[dictionary + 4], 0, "the second segment is the symbol dictionary"); + let (rest, page) = end(dictionary); + let mut symbols = coded[dictionary..rest].to_vec(); + symbols[page - dictionary] = 0; + SharedScan { page: [&coded[..dictionary], &coded[rest..]].concat(), symbols, bits } +} + +/// An image of [`SharedScan`]'s size that uses the dictionary, object 9, by `parms`. +fn shared_image(parms: &str) -> String { + format!("/Type /XObject /Subtype /Image /Width 400 /Height 40 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode {parms}") +} + +/// Two pages of a scan sharing object 9: its symbols, and `LEAK_GLOBALS`, for the shape of a mark found +/// only under the area. The first page draws image 7 unless told otherwise; the second draws image 8. +/// `extra` are objects 10 on. +struct SharedPages { + /// What the first page draws, its `/Resources` entries and anything else in its dictionary. + content: Vec, + resources: String, + page: String, + /// Image 7's dictionary and data; image 8's. + first: String, + first_data: Vec, + other: String, + other_data: Vec, + extra: Vec>, +} + +const DRAWS: &[u8] = b"q 200 0 0 20 0 100 cm /Im Do Q"; + +impl SharedPages { + fn new(scan: &SharedScan) -> Self { + let image = shared_image(SHARED); + Self { + content: DRAWS.to_vec(), + resources: "/XObject << /Im 7 0 R >>".into(), + page: String::new(), + first: image.clone(), + first_data: scan.page.clone(), + other: image, + other_data: scan.page.clone(), + extra: vec![], + } + } + + fn build(self, scan: &SharedScan) -> Document { + let Self { content, resources, page, first, first_data, other, other_data, extra } = self; + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Resources << {resources} >> {page} >>").into_bytes(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Resources << /XObject << /Im 8 0 R >> >> >>".to_vec(), + stream("", content), + stream("", DRAWS), + stream(&first, first_data), + stream(&other, other_data), + stream("", [jbig2_globals(b"LEAK_GLOBALS"), scan.symbols.clone()].concat()), + ]; + objs.extend(extra); + pdf(objs) + } +} + +/// The samples of the image the page draws as `/Im`. +fn drawn_samples(doc: &Document, page: usize) -> Vec { + let p = pdfcraft_model::pages(doc).swap_remove(page); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + match &*doc.get(xo.get(b"Im").unwrap().as_ref().unwrap()) { + Object::Stream(s) => decoded_image(doc, s).unwrap(), + other => panic!("not an image: {other:?}"), + } +} + +const SHARED: &str = "/DecodeParms << /JBIG2Globals 9 0 R >>"; +const CLEARS: [f64; 4] = [0.25, 113.75, 49.75, 120.0]; +const COVERS: [f64; 4] = [0.0, 100.0, 200.0, 120.0]; + +/// The ways image 7 can leave the document while taking the dictionary's only reason to stay with it. +fn ways_out(scan: &SharedScan) -> Vec<(&'static str, [f64; 4], SharedPages)> { + let base = || SharedPages::new(scan); + let in_form = |filter: &str, data: &[u8]| { + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Im 7 0 R >> >> {filter}"), data) + }; + let plain = "/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray"; + let through_form = |filter: &str, data: &[u8]| SharedPages { + content: b"/Fm Do".to_vec(), + resources: "/XObject << /Fm 10 0 R >>".into(), + extra: vec![in_form(filter, data)], + ..base() + }; + let annotated = |subtype: &str| SharedPages { + content: Vec::new(), + resources: String::new(), + page: "/Annots [10 0 R]".into(), + extra: vec![ + format!("<< /Type /Annot /Subtype /{subtype} /T (Field) /FT /Tx /Rect [0 100 200 120] /AP << /N 11 0 R >> >>").into_bytes(), + in_form("", DRAWS), + ], + ..base() + }; + let glyph = SharedPages { + content: b"BT /T3 200 Tf 0 100 Td (a) Tj ET".to_vec(), + resources: "/Font << /T3 10 0 R >>".into(), + extra: vec![ + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] /CharProcs << /a 11 0 R >> \ + /Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [1000] /Resources << /XObject << /Im 7 0 R >> >> >>" + .to_vec(), + stream("", b"1000 0 d0 q 1000 0 0 100 0 0 cm /Im Do Q"), + ], + ..base() + }; + vec![ + ("cleared", CLEARS, base()), + ("removed", COVERS, base()), + ("in a form", COVERS, through_form("", DRAWS)), + ("in a form that can't be read", COVERS, through_form("/Filter /FlateDecode", b"not Flate")), + ("a stamp's appearance", COVERS, annotated("Stamp")), + ("a widget's appearance", COVERS, annotated("Widget")), + ("a Type 3 glyph", COVERS, glyph), + ( + "a mask", + COVERS, + SharedPages { first: format!("{plain} /SMask 10 0 R"), extra: vec![stream(&shared_image(SHARED), scan.page.clone())], ..base() }, + ), + ( + "a stencil mask", + COVERS, + SharedPages { + first: format!("{plain} /Mask 10 0 R"), + extra: vec![stream( + &shared_image(SHARED).replace("/BitsPerComponent 1 /ColorSpace /DeviceGray", "/ImageMask true"), + scan.page.clone(), + )], + ..base() + }, + ), + ( + "parameters for data in another file", + COVERS, + SharedPages { first: shared_image("/F (scan.jb2) /FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 9 0 R >>"), ..base() }, + ), + ( + "an alternate", + COVERS, + SharedPages { + first: format!("{plain} /Alternates [<< /Image 10 0 R >>]"), + extra: vec![stream(&shared_image(SHARED), scan.page.clone())], + ..base() + }, + ), + ( + "/Filter by reference", + COVERS, + SharedPages { + first: shared_image("/DecodeParms [null << /JBIG2Globals 9 0 R >>]").replace("/Filter /JBIG2Decode", "/Filter 10 0 R"), + extra: vec![b"[/ASCIIHexDecode /JBIG2Decode]".to_vec()], + ..base() + }, + ), + ( + "a reference to it", + COVERS, + SharedPages { first: shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"), extra: vec![b"9 0 R".to_vec()], ..base() }, + ), + ( + "parameters through a reference to them", + COVERS, + SharedPages { + first: shared_image("/DecodeParms 10 0 R"), + extra: vec![b"11 0 R".to_vec(), b"<< /JBIG2Globals 9 0 R >>".to_vec()], + ..base() + }, + ), + ( + "an entry through a reference to it", + COVERS, + SharedPages { + first: shared_image("/DecodeParms [10 0 R]"), + extra: vec![b"11 0 R".to_vec(), b"<< /JBIG2Globals 9 0 R >>".to_vec()], + ..base() + }, + ), + ( + "an array of them through a reference to it", + COVERS, + SharedPages { + first: shared_image("/DecodeParms 10 0 R"), + extra: vec![b"11 0 R".to_vec(), b"[<< /JBIG2Globals 9 0 R >>]".to_vec()], + ..base() + }, + ), + ("/DP", COVERS, SharedPages { first: shared_image("/DP << /JBIG2Globals 9 0 R >>"), ..base() }), + // Cleared, the copy keeps no parameters of the old data, so none names the dictionary. + ("/DP, cleared", CLEARS, SharedPages { first: format!("{} /DP << /JBIG2Globals 9 0 R >>", shared_image(SHARED)), ..base() }), + ( + "parameters for data in another file, with none, cleared", + CLEARS, + SharedPages { first: format!("{} /FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 9 0 R >>", shared_image(SHARED)), ..base() }, + ), + ("another generation", COVERS, SharedPages { first: shared_image("/DecodeParms << /JBIG2Globals 9 1 R >>"), ..base() }), + // Cos reads a reference of another generation as null (#1143); a reader that doesn't compare + // generations draws with what it names. Read first, it mustn't hide what the other names. + ( + "parameters through another generation, then their own", + COVERS, + SharedPages { + first: shared_image("/DecodeParms [10 1 R 10 0 R]").replace("/Filter /JBIG2Decode", "/Filter [/ASCIIHexDecode /JBIG2Decode]"), + extra: vec![b"<< /JBIG2Globals 9 0 R >>".to_vec()], + ..base() + }, + ), + ( + "parameters through another generation", + COVERS, + SharedPages { first: shared_image("/DecodeParms 10 1 R"), extra: vec![b"<< /JBIG2Globals 9 0 R >>".to_vec()], ..base() }, + ), + ( + "a reference to it of another generation", + COVERS, + SharedPages { first: shared_image("/DecodeParms << /JBIG2Globals 10 1 R >>"), extra: vec![b"9 0 R".to_vec()], ..base() }, + ), + ] +} + +/// Scanners and `jbig2enc -s` share one symbol dictionary across a scan's pages, and it holds the +/// shape of every distinct mark, one found only under a redaction included: a signature, a stamp. +/// The dictionary stayed in the file while any other image used it. Now the image left on the second +/// page is stored again without it, whichever way the first one left the document. +#[test] +fn a_redacted_scans_shared_jbig2_dictionary_leaves_the_file() { + let scan = shared_scan(); + for (way, rect, pages) in ways_out(&scan) { + let mut doc = pages.build(&scan); + assert_eq!(drawn_samples(&doc, 1), scan.bits, "{way}: the second page reads through the dictionary"); + mark(&mut doc, 0, &[rect], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.images_restored, 1, "{way}"); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_gone(&doc, b"JBIG2Globals"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits, "{way}: its pixels are unchanged"); + } +} + +/// A size given through a reference of another generation is null, as cos reads it (#1143), whatever +/// another image read through the reference of that number's own: the image can't be stored again, so +/// the redaction refuses, rather than storing it with the size the other reference read. +#[test] +fn a_size_through_another_generation_isnt_the_one_its_own_reference_reads() { + let scan = shared_scan(); + let stale = shared_image(SHARED).replace("/Width 400", "/Width 11 1 R"); + let mut doc = SharedPages { + content: [DRAWS, b" q 200 0 0 20 0 200 cm /Stale Do Q".as_slice()].concat(), + resources: "/XObject << /Im 7 0 R /Stale 12 0 R >>".into(), + other: shared_image(SHARED).replace("/Width 400", "/Width 11 0 R"), + extra: vec![b"null".to_vec(), b"400".to_vec(), stream(&stale, scan.page.clone())], + ..SharedPages::new(&scan) + } + .build(&scan); + mark(&mut doc, 0, &[COVERS], ""); + let result = apply(&mut doc, None); + assert!(matches!(result, Err(RedactError::SharedJbig2Globals(1))), "{result:?}"); +} + +/// An image whose cross-reference row and object header give different generations is read through +/// either (#1143), and page 2 names it by the header's. Stored again without the dictionary, it keeps +/// both, so that page still draws it, in the document and in a full save. +#[test] +fn an_image_stored_again_keeps_the_generation_its_header_gives() { + let scan = shared_scan(); + let bytes = SharedPages::new(&scan).build(&scan).bytes().to_vec(); + let patch = |bytes: Vec, from: &[u8], to: &[u8]| { + let at = bytes.windows(from.len()).position(|w| w == from).unwrap(); + assert_eq!(bytes.windows(from.len()).filter(|w| *w == from).count(), 1); + [&bytes[..at], to, &bytes[at + from.len()..]].concat() + }; + let bytes = patch(patch(bytes, b"\n8 0 obj", b"\n8 1 obj"), b"/Im 8 0 R", b"/Im 8 1 R"); + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(drawn_samples(&doc, 1), scan.bits, "the second page reads its image through the header's generation"); + mark(&mut doc, 0, &[COVERS], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_restored, 1); + assert_eq!(drawn_samples(&doc, 1), scan.bits, "the image stored again is still drawn"); + let full = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!full.windows(12).any(|w| w == b"LEAK_GLOBALS"), "the dictionary left the full save"); + assert_eq!(drawn_samples(&Document::open(Arc::new(full)).unwrap(), 1), scan.bits, "and is drawn after it"); + assert_gone(&doc, b"LEAK_GLOBALS"); +} + +/// However the image that stays names the dictionary, it comes out without it: its parameters by +/// reference, behind another filter, or a copy of the dictionary, as a file put back together from +/// pages split apart has, each page with its own. It is also read when its size or filter is given +/// by reference, which `decoded_image` alone refuses. +#[test] +fn what_stays_is_stored_without_the_dictionary_however_it_names_it() { + let scan = shared_scan(); + let hex: Vec = scan.page.iter().map(|b| format!("{b:02x}")).collect::().bytes().chain(*b">").collect(); + let copy = stream("", [jbig2_globals(b"LEAK_GLOBALS"), scan.symbols.clone()].concat()); + let image = shared_image(SHARED); + for (other, data, extra) in [ + (shared_image("/DecodeParms 10 0 R"), scan.page.clone(), vec![b"<< /JBIG2Globals 9 0 R >>".to_vec()]), + ( + shared_image("/DecodeParms [null << /JBIG2Globals 9 0 R >>]").replace("/Filter /JBIG2Decode", "/Filter [/ASCIIHexDecode /JBIG2Decode]"), + hex.clone(), + vec![], + ), + (shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"), scan.page.clone(), vec![copy]), + (image.replace("/Width 400", "/Width 10 0 R"), scan.page.clone(), vec![b"400".to_vec()]), + (image.replace("/Height 40", "/Height 10 0 R"), scan.page.clone(), vec![b"40".to_vec()]), + (image.replace("/Filter /JBIG2Decode", "/Filter 10 0 R"), scan.page.clone(), vec![b"/JBIG2Decode".to_vec()]), + ( + shared_image("/DecodeParms [null << /JBIG2Globals 9 0 R >>]").replace("/Filter /JBIG2Decode", "/Filter [/ASCIIHexDecode 10 0 R]"), + hex.clone(), + vec![b"/JBIG2Decode".to_vec()], + ), + (image.replace("/ColorSpace /DeviceGray ", ""), scan.page.clone(), vec![]), + ( + image.replace("/ColorSpace /DeviceGray", "/ColorSpace [/DeviceN [/Black] /DeviceGray 10 0 R]"), + scan.page.clone(), + vec![b"<< /FunctionType 2 /Domain [0 1] /N 1 >>".to_vec()], + ), + // Parameters no filter reads, on a picture that needs no dictionary. + (shared_image("/DP << /JBIG2Globals 9 0 R >>"), pdfcraft_filters::encode_jbig2_generic(&scan.bits, 400, 40).unwrap(), vec![]), + // Those for data in another file, with none: beside the parameters it reads, and alone. + (format!("{image} /FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 9 0 R >>"), scan.page.clone(), vec![]), + ( + shared_image("/FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 9 0 R >>"), + pdfcraft_filters::encode_jbig2_generic(&scan.bits, 400, 40).unwrap(), + vec![], + ), + // A `/F` that is null, directly, by reference, or as a reference to no object, is none. + (format!("{image} /F null"), scan.page.clone(), vec![]), + (format!("{image} /F 10 0 R"), scan.page.clone(), vec![b"null".to_vec()]), + (format!("{image} /F 99 0 R"), scan.page.clone(), vec![]), + // Through a reference to a reference: /F to null, the size, the filter, the colour space. + (format!("{image} /F 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"null".to_vec()]), + (shared_image("/DecodeParms 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"<< /JBIG2Globals 9 0 R >>".to_vec()]), + (image.replace("/Width 400", "/Width 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"400".to_vec()]), + (image.replace("/Height 40", "/Height 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"40".to_vec()]), + (image.replace("/Filter /JBIG2Decode", "/Filter 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"/JBIG2Decode".to_vec()]), + ( + shared_image("/DecodeParms [null << /JBIG2Globals 9 0 R >>]").replace("/Filter /JBIG2Decode", "/Filter [/ASCIIHexDecode 10 0 R]"), + hex.clone(), + vec![b"11 0 R".to_vec(), b"/JBIG2Decode".to_vec()], + ), + (image.replace("/ColorSpace /DeviceGray", "/ColorSpace 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"/DeviceGray".to_vec()]), + // An ICC profile of one component is one. + (format!("{} /ColorSpace [/ICCBased 10 0 R]", image.replace("/ColorSpace /DeviceGray ", "")), scan.page.clone(), vec![stream("/N 1", b"")]), + ] { + let mut doc = SharedPages { other: other.clone(), other_data: data, extra, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.images_restored, 1, "{other}"); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits, "{other}: its pixels are unchanged"); + } +} + +/// JBIG2 is drawn as one bit of one component whatever the dictionary says, so the Flate copy has to +/// say so too: a stream with no `/BitsPerComponent`, or 8, shows the same picture stored again. +#[test] +fn a_stored_jbig2_image_is_one_bit_whatever_its_dictionary_said() { + let scan = shared_scan(); + let hex: Vec = scan.page.iter().map(|b| format!("{b:02x}")).collect::().bytes().chain(*b">").collect(); + let chained = + shared_image("/DecodeParms [null << /JBIG2Globals 9 0 R >>]").replace("/Filter /JBIG2Decode", "/Filter [/ASCIIHexDecode /JBIG2Decode]"); + let lone = shared_image(SHARED).replace("/Filter /JBIG2Decode", "/Filter [/JBIG2Decode]"); + for (other, other_data, bits) in [ + (shared_image(SHARED), scan.page.clone(), ""), + (shared_image(SHARED), scan.page.clone(), "/BitsPerComponent 8"), + (chained, hex, ""), + (lone, scan.page.clone(), ""), + ] { + let other = other.replace("/BitsPerComponent 1", bits); + let mut doc = SharedPages { other, other_data, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + apply(&mut doc, None).unwrap(); + let saved = reopen(&doc); + assert_eq!(drawn_samples(&saved, 1), scan.bits, "{bits}"); + let p = pdfcraft_model::pages(&saved).swap_remove(1); + let res = saved.resolve(p.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = saved.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let image = saved.get(xo.get(b"Im").unwrap().as_ref().unwrap()); + let Object::Stream(s) = &*image else { panic!("not an image") }; + assert_eq!(s.dict.int(b"BitsPerComponent"), Some(1), "{bits}"); + } +} + +/// A mark that takes nothing using the dictionary leaves it, and what uses it, alone: here it covers +/// text in a form that also draws the scan, away from the scan. A stream nothing reaches isn't decoded +/// for nothing either: this one can't be, and it uses the dictionary. +#[test] +fn a_dictionary_stays_when_nothing_using_it_left() { + let scan = shared_scan(); + let text = b"BT /F1 10 Tf 10 250 Td (SECRET) Tj ET q 200 0 0 20 0 100 cm /Im Do Q"; + let pages = SharedPages { + content: b"/Fm Do".to_vec(), + resources: "/XObject << /Fm 10 0 R >>".into(), + extra: vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im 7 0 R >> >>", + text, + ), + stream(&shared_image(SHARED), b"not JBIG2"), + ], + ..SharedPages::new(&scan) + }; + let mut doc = pages.build(&scan); + mark(&mut doc, 0, &[[0.0, 240.0, 200.0, 270.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_removed, report.images_cleared, report.images_restored), (0, 0, 0)); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits); +} + +/// A dictionary of other bytes is another dictionary: the scan using it keeps it and isn't stored again. +#[test] +fn another_jbig2_dictionary_is_left_alone() { + let scan = shared_scan(); + let own = stream("", [jbig2_globals(b"OWN_GLOBALS"), scan.symbols.clone()].concat()); + let other = shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"); + let mut doc = SharedPages { other, extra: vec![own], ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_restored, 0); + let saved = reopen(&doc); + let holds = |secret: &[u8]| { + saved.scan_objects().any(|(_, o)| matches!(&*o, Object::Stream(s) if s.decoded().is_ok_and(|d| d.windows(secret.len()).any(|w| w == secret)))) + }; + assert!(!holds(b"LEAK_GLOBALS") && holds(b"OWN_GLOBALS")); + assert_eq!(drawn_samples(&saved, 1), scan.bits); +} + +/// An image that can't be stored without the dictionary may still be drawn: the renderer draws a +/// JBIG2 page of another size than its image, which `decoded_image` refuses, and draws three components +/// of one bit as grey. Removing it would lose a page no mark reached, and keeping the dictionary would +/// keep what the mark covered, so the redaction refuses and the document stays as it was. So it does +/// for anything else that refers to the dictionary, a reference that holds a reference to it among them. +#[test] +fn a_shared_jbig2_dictionary_that_cant_leave_refuses_the_redaction() { + let scan = shared_scan(); + let image = shared_image(SHARED); + let elsewhere = "/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 1 /ColorSpace /DeviceGray /Note 9 0 R"; + let not_jbig2 = image.replace("/Filter /JBIG2Decode", "/Filter /FlateDecode"); + for (other, data, extra) in [ + (image.replace("/Height 40", "/Height 41"), scan.page.clone(), vec![]), + (image.replace("/DeviceGray", "/DeviceRGB"), scan.page.clone(), vec![]), + (image.replace("/DeviceGray", "/DeviceCMYK"), scan.page.clone(), vec![]), + (image.replace("/ColorSpace /DeviceGray", "/ColorSpace 10 0 R"), scan.page.clone(), vec![b"11 0 R".to_vec(), b"/DeviceRGB".to_vec()]), + ( + format!("{image} /ColorSpace [/ICCBased 10 0 R]").replace("/ColorSpace /DeviceGray ", ""), + scan.page.clone(), + vec![b"11 0 R".to_vec(), stream("/N 3", b"")], + ), + // Through a reference to a reference, which now reads as what it names (#5548): stored again + // without the dictionary, or refused (see below). + (shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"), scan.page.clone(), vec![b"9 0 R".to_vec()]), + (elsewhere.to_string(), vec![0], vec![]), + (not_jbig2, pdfcraft_filters::encode_flate(&scan.bits), vec![]), + // A copy with its filter by reference is known for a copy, and the decoder doesn't read it. + ( + shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"), + scan.page.clone(), + vec![ + stream("/Filter 11 0 R", pdfcraft_filters::encode_flate(&[jbig2_globals(b"LEAK_GLOBALS"), scan.symbols.clone()].concat())), + b"/FlateDecode".to_vec(), + ], + ), + ] { + let through_reference = other.contains("/JBIG2Globals 10 0 R"); + let mut doc = SharedPages { other: other.clone(), other_data: data, extra, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + let before = doc.clone(); + match apply(&mut doc, None) { + Err(RedactError::SharedJbig2Globals(_)) => assert_eq!(drawn_samples(&doc, 0), drawn_samples(&before, 0), "{other}: nothing was redacted"), + Ok(_) if through_reference => { + // Object 10 too, the copy or the reference to the dictionary: `assert_gone` can't + // decode the copy, its `/Filter` given by reference, nor see its bytes, which are Flate. + let kept = pdfcraft_cos::reachable_objects(&doc).unwrap(); + assert!(!kept.contains(&9) && !kept.contains(&10), "{other}: the dictionary or its copy stays"); + assert_gone(&doc, b"LEAK_GLOBALS"); + } + result => panic!("{other}: {result:?}"), + } + } +} + +/// What is stored again is bounded as what is cleared is: an image of [`image::MAX_PIXELS`] is, and one +/// past it, which decodes, refuses the redaction. +#[test] +fn an_image_past_the_size_redaction_clears_refuses_it() { + let scan = shared_scan(); + let h = 8192u32; + for (w, stored) in [(8192u32, true), (8200, false)] { + let blank = vec![0xFFu8; (w as usize).div_ceil(8) * h as usize]; + let other = shared_image(SHARED).replace("/Width 400", &format!("/Width {w}")).replace("/Height 40", &format!("/Height {h}")); + let data = pdfcraft_filters::encode_jbig2_generic(&blank, w, h).unwrap(); + let mut doc = SharedPages { other, other_data: data, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + let run = apply(&mut doc, None); + assert_eq!(run.as_ref().is_ok_and(|r| r.images_restored == 1), stored, "{w}"); + assert_eq!(matches!(run, Err(RedactError::SharedJbig2Globals(1))), !stored, "{w}"); + } +} + +/// An alternate of an image stored again stays with it, stored without the dictionary in turn: a page +/// no mark reached keeps what it had. +#[test] +fn an_alternate_of_what_stays_stays() { + let scan = shared_scan(); + let other = format!("{} /Alternates [<< /Image 10 0 R >>]", shared_image(SHARED)); + let mut doc = SharedPages { other, extra: vec![stream(&shared_image(SHARED), scan.page.clone())], ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_restored, 2); + assert_gone(&doc, b"LEAK_GLOBALS"); + let saved = reopen(&doc); + let kept = saved.scan_objects().filter(|(_, o)| matches!(&**o, Object::Stream(s) if s.dict.contains(b"Alternates"))).count(); + assert_eq!(kept, 1); +} + +/// The refusal counts the images that can't be stored, and says so; when only something else refers +/// to the dictionary, it counts none and says that instead, however many such references there are. +#[test] +fn a_refused_redaction_counts_what_cant_be_stored() { + let scan = shared_scan(); + let wrong = shared_image(SHARED).replace("/Height 40", "/Height 41"); + let other = format!("{wrong} /SMask 10 0 R"); + let mut doc = SharedPages { other, extra: vec![stream(&wrong, scan.page.clone())], ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + let refused = apply(&mut doc, None); + assert!(matches!(refused, Err(RedactError::SharedJbig2Globals(2)))); + assert!(refused.unwrap_err().to_string().starts_with("2 item(s) share JBIG2 symbol data with something being removed and can't be stored")); + let note = "/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 1 /ColorSpace /DeviceGray /Note 9 0 R"; + for (other, extra, stuck) in [ + (note.to_string(), vec![], 0), + (format!("{note} /SMask 10 0 R"), vec![stream(note, vec![0])], 0), + (format!("{note} /SMask 10 0 R"), vec![stream(&wrong, scan.page.clone())], 1), + ] { + let mut doc = SharedPages { other: other.clone(), other_data: vec![0], extra, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + let refused = apply(&mut doc, None); + assert!(matches!(refused, Err(RedactError::SharedJbig2Globals(n)) if n == stuck), "{other}"); + let text = refused.unwrap_err().to_string(); + assert_eq!(text.starts_with("something else in the file refers to JBIG2 symbol data"), stuck == 0, "{other}: {text}"); + assert_eq!(text.contains("can't be stored"), stuck > 0, "{other}: {text}"); + } + // An image whose data is in another file can't be stored here, though what is here decodes: readers + // draw the other file, with the dictionary its own parameters name. + let elsewhere = format!("{} /F (page2.jb2) /FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 9 0 R >>", shared_image(SHARED)); + let mut doc = SharedPages { other: elsewhere, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1)))); +} + +/// A dictionary that names itself as its own globals (malformed) can't be stored without them, but +/// leaves with what used it, so the run goes on; it isn't counted as stored, nor, when the run is +/// refused, as something that uses the dictionary. +#[test] +fn a_dictionary_that_names_itself_isnt_counted_as_stored() { + let scan = shared_scan(); + let names_itself = |pages: SharedPages| { + let mut doc = pages.build(&scan); + let dictionary = pdfcraft_cos::ObjRef::new(9, 0); + let Object::Stream(mut s) = (*doc.get(dictionary)).clone() else { panic!("object 9 is the dictionary") }; + let mut parms = pdfcraft_cos::Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(dictionary)); + s.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + doc.set(dictionary, Object::Stream(s)); + mark(&mut doc, 0, &[CLEARS], ""); + doc + }; + let mut doc = names_itself(SharedPages::new(&scan)); + assert_eq!(apply(&mut doc, None).unwrap().images_restored, 1); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits); + // The second page's image has its data in another file, so it can't be stored: one item. + let mut doc = names_itself(SharedPages { other: format!("{} /F (p.jb2)", shared_image(SHARED)), ..SharedPages::new(&scan) }); + assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1)))); +} + +/// What a stream's size, filters and parameters are made direct to is small, however often they +/// refer to a large object: only numbers, names and the parameters decoders read are copied. +#[test] +fn what_is_made_direct_is_small_however_often_a_large_object_is_named() { + let big = format!("[{}]", "1 ".repeat(100_000)); + let keys: String = (0..50).map(|k| format!(" /K{k} 2 0 R")).collect(); + // Three filters, and parameters for 2,001: the entries past the filters are dropped. Two name + // object 6, whose `/JBIG2Globals` is the large array given directly, which no decoder reads. + let (filters, entries) = (" 2 0 R".repeat(2), format!(" 6 0 R 6 0 R{}", " 2 0 R".repeat(1998))); + let dict = + format!("/Width 2 0 R /Height 2 0 R /Filter [/FlateDecode{filters}] /DecodeParms [<< /Predictor 3 0 R /Columns 2 0 R{keys} >>{entries}]"); + let globals = format!("<< /JBIG2Globals {big} >>"); + // A chain of more filters than any decoder takes is no filter, and its parameters aren't read: + // ten here, against eight and a `/Crypt`. + let long = format!("/Filter [{}] /DecodeParms 2 0 R", "/FlateDecode ".repeat(10)); + let doc = pdf(vec![ + b"<< /Type /Catalog >>".to_vec(), + big.into_bytes(), + b"4 0 R".to_vec(), + b"12".to_vec(), + stream(&dict, b""), + globals.into_bytes(), + stream(&long, b""), + stream(&format!("/Filter [{}]", "/FlateDecode ".repeat(9)), b""), + // A name no decoder knows, long or short, is no filter, and isn't copied however often. + format!("/{}", "A".repeat(2000)).into_bytes(), + format!("[{} /Foo]", " 9 0 R".repeat(8)).into_bytes(), + stream("/Filter 10 0 R", b""), + ]); + let Object::Stream(s) = &*doc.get(ObjRef::new(5, 0)) else { panic!("object 5 is the stream") }; + let made = crate::globals::Run::new(crate::globals::RUN_BUDGET).readable(&doc, s).unwrap(); + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Dict(made.dict.clone()), &mut bytes); + assert!(bytes.len() < 4096, "{} bytes made direct", bytes.len()); + let parms = made.dict.get(b"DecodeParms").and_then(Object::as_array).unwrap(); + assert_eq!(parms.first().and_then(Object::as_dict).and_then(|d| d.get(b"Predictor")), Some(&Object::Int(12))); + assert_eq!(parms.len(), 3, "an entry for each filter"); + assert!(parms[1].as_dict().is_some_and(|d| !d.contains(b"JBIG2Globals")), "{:?}", parms[1]); + let Object::Stream(s) = &*doc.get(ObjRef::new(7, 0)) else { panic!("object 7 is the stream") }; + let made = crate::globals::Run::new(crate::globals::RUN_BUDGET).readable(&doc, s).unwrap(); + assert_eq!(made.dict.get(b"Filter"), Some(&Object::Null)); + assert_eq!(made.dict.get(b"DecodeParms").and_then(Object::as_array).map(Vec::len), Some(0)); + // Eight filters and a `/Crypt` are as many as a decoder takes: those are read. + let Object::Stream(s) = &*doc.get(ObjRef::new(8, 0)) else { panic!("object 8 is the stream") }; + let made = crate::globals::Run::new(crate::globals::RUN_BUDGET).readable(&doc, s).unwrap(); + assert_eq!(made.dict.get(b"Filter").and_then(Object::as_array).map(Vec::len), Some(9)); + let Object::Stream(s) = &*doc.get(ObjRef::new(11, 0)) else { panic!("object 11 is the stream") }; + let made = crate::globals::Run::new(crate::globals::RUN_BUDGET).readable(&doc, s).unwrap(); + assert_eq!(made.dict.get(b"Filter"), Some(&Object::Array(vec![Object::Null; 9]))); +} + +/// A `/JBIG2Globals` reference in parameters given directly is fetched once a run, however many +/// entries and streams name it, and what it leads to is held once. +#[test] +fn a_dictionary_named_in_direct_entries_is_fetched_once() { + let entries = " << /JBIG2Globals 2 0 R >>".repeat(9); + let mut objects = vec![b"<< /Type /Catalog >>".to_vec(), stream("", b"symbols")]; + objects.extend((0..50).map(|_| stream(&format!("/Filter /JBIG2Decode /DecodeParms [{entries}]"), b""))); + let doc = pdf(objects); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + for n in 3..53 { + let Object::Stream(s) = &*doc.get(ObjRef::new(n, 0)) else { panic!("object {n} is a stream") }; + let lists = run.names_of(&doc, s).unwrap(); + assert_eq!(lists.iter().map(|(list, _)| list.to_vec()).collect::>(), [vec![2]]); + } + assert_eq!((run.fetched, run.spent), (1, 2), "one fetch, and the one number held"); +} + +/// A `/JBIG2Globals` reference leading through a chain names each reference on the way, up to the +/// limit of 33, whether the chain is met first part way or from its start. +#[test] +fn a_chain_of_references_is_named_to_the_limit() { + // Objects 2 to 41 each refer to the next, and 42 is the dictionary; 43 names the chain from 20, + // and 44 from its start. + let mut objects = vec![b"<< /Type /Catalog >>".to_vec()]; + objects.extend((3..=42).map(|n| format!("{n} 0 R").into_bytes())); + objects.push(stream("", b"symbols")); + objects.push(stream("/Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 20 0 R >>", b"")); + objects.push(stream("/Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 2 0 R >>", b"")); + let doc = pdf(objects); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + let named = |run: &mut crate::globals::Run, n: u32| { + let Object::Stream(s) = &*doc.get(ObjRef::new(n, 0)) else { panic!("object {n} is a stream") }; + run.names_of(&doc, s).unwrap().iter().flat_map(|(list, _)| list.to_vec()).collect::>() + }; + assert_eq!(named(&mut run, 43), (20..=42).collect::>()); + assert_eq!(named(&mut run, 44), (2..=34).collect::>()); +} + +/// An image is charged the decoded size of each dictionary it names, as decoding it may undo that +/// dictionary's filters again. +#[test] +fn an_image_is_charged_the_dictionaries_it_names() { + let image = "/Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 1 /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 2 0 R >>"; + let doc = pdf(vec![b"<< /Type /Catalog >>".to_vec(), stream("", vec![0u8; 40_000]), stream(image, b"not JBIG2")]); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + crate::globals::content(&mut run, &doc, 2, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!(run.spent, 10, "40,000 bytes: a unit and nine whole 4 KiB"); + let Object::Stream(s) = &*doc.get(ObjRef::new(3, 0)) else { panic!("object 3 is the image") }; + assert!(matches!(crate::globals::without_dictionary(&mut run, &doc, s, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)), Ok(None))); + // The chain to 2 (a fetch and the number held), the dictionary's 10, the image's allowance 2. + assert_eq!(run.spent, 24); +} + +/// A dictionary pays the run's image allowance for what every stage of its filters reads and makes, +/// not for what the last one makes: two Flates inflate a MiB of zero bytes, which hex skips as white +/// space, so it decodes to nothing. Past the allowance the run is refused, through `unshare` too. +#[test] +fn a_dictionary_pays_for_every_stage_of_its_filters() { + let hollow = pdfcraft_filters::encode_flate(&pdfcraft_filters::encode_flate(&vec![0u8; 1 << 20])); + let dict = || stream("/Filter [/FlateDecode /FlateDecode /ASCIIHexDecode]", hollow.clone()); + let doc = pdf(vec![b"<< /Type /Catalog >>".to_vec(), dict()]); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + let mut images = pdfcraft_cos::ImageBudget::with_work(u64::MAX); + assert!(crate::globals::content(&mut run, &doc, 2, &mut images).unwrap().is_some()); + assert_eq!(run.spent, 1, "it decodes to nothing: a unit"); + let paid = u64::MAX - images.work_left(); + assert!(paid > 1 << 20, "the MiB the second Flate made is paid for: {paid} steps"); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + let refused = crate::globals::content(&mut run, &doc, 2, &mut pdfcraft_cos::ImageBudget::with_work(1 << 20)); + assert_eq!(refused, Err(RedactError::ImagesPastAllowance)); + + // An image naming it leaves its page: finding copies of the dictionary reads it. + let image = "/Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 1 /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 8 0 R >>"; + let mut doc = one_page(b"q 8 0 0 8 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(image, b""), dict()]); + let before = crate::globals::kept(&doc).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + let refused = crate::globals::unshare(&mut doc.clone(), &before, &mut pdfcraft_cos::ImageBudget::with_work(1 << 20)); + assert_eq!(refused, Err(RedactError::ImagesPastAllowance)); + assert_eq!(crate::globals::unshare(&mut doc, &before, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)), Ok(0)); +} + +/// What can't be decoded is charged as much as it was allowed to decode, so going past a limit +/// costs as much as reaching it: a dictionary `MAX_JBIG2_GLOBALS` (here one behind a filter that +/// isn't decoded here), an image its allowance. +#[test] +fn a_decode_that_fails_is_charged_what_it_was_allowed() { + let image = "/Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 1 /Filter /JBIG2Decode"; + let doc = pdf(vec![b"<< /Type /Catalog >>".to_vec(), stream("/Filter /DCTDecode", b"not a JPEG"), stream(image, b"not JBIG2")]); + let units = pdfcraft_cos::MAX_JBIG2_GLOBALS / 4096 + 1; + let run = |budget| crate::globals::Run::new(budget); + let images = || pdfcraft_cos::ImageBudget::with_work(u64::MAX); + assert!(crate::globals::content(&mut run(units), &doc, 2, &mut images()).is_ok()); + assert_eq!(crate::globals::content(&mut run(units - 1), &doc, 2, &mut images()), Err(RedactError::SharedJbig2Budget)); + // The image's allowance: (one byte a row of 8, and a byte a row) × 3 a byte, and 4096: 4144 + // bytes, two units. + let Object::Stream(s) = &*doc.get(ObjRef::new(3, 0)) else { panic!("object 3 is the image") }; + assert!(matches!(crate::globals::without_dictionary(&mut run(2), &doc, s, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)), Ok(None))); + assert!(matches!( + crate::globals::without_dictionary(&mut run(1), &doc, s, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)), + Err(RedactError::SharedJbig2Budget) + )); +} + +/// What streams share by reference is read once a run, however many share it and however they +/// refer to it: a parameters array of 5,000 entries named by 100 streams, directly, each through an +/// object of its own that refers to it, or through a chain of two, costs the array and its one +/// entry, once. +#[test] +fn a_shared_parameters_array_is_read_once_however_many_streams_share_it() { + let entries = " 4 0 R".repeat(5000); + let mut objects = + vec![b"<< /Type /Catalog >>".to_vec(), format!("[{entries}]").into_bytes(), stream("", b"symbols"), b"<< /JBIG2Globals 3 0 R >>".to_vec()]; + // Objects 5 to 54 refer to the array, and 55 to 5. + objects.extend((0..50).map(|_| b"2 0 R".to_vec())); + objects.push(b"5 0 R".to_vec()); + // Streams 56 to 155: fifty name the array directly, forty-nine through objects 5 to 53, one + // through the chain from 55. + objects.extend((0..50).map(|_| stream("/Filter /JBIG2Decode /DecodeParms 2 0 R", b""))); + objects.extend((5..54).map(|n| stream(&format!("/Filter /JBIG2Decode /DecodeParms {n} 0 R"), b""))); + objects.push(stream("/Filter /JBIG2Decode /DecodeParms 55 0 R", b"")); + let doc = pdf(objects); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + for n in 56..156 { + let Object::Stream(s) = &*doc.get(ObjRef::new(n, 0)) else { panic!("object {n} is a stream") }; + let lists = run.names_of(&doc, s).unwrap(); + assert_eq!(lists.iter().map(|(list, _)| list.to_vec()).collect::>(), [vec![3]]); + } + assert_eq!(run.read, 2); + // Each object once: the array, its entry, the dictionary it names, each of the 49 objects that + // refer to the array, and 55. + assert_eq!(run.fetched, 53); + // A run whose budget that is past is refused. + let mut run = crate::globals::Run::new(20); + let refused = (56..156).try_for_each(|n| { + let Object::Stream(s) = &*doc.get(ObjRef::new(n, 0)) else { panic!("object {n} is a stream") }; + run.names_of(&doc, s).map(|_| ()) + }); + assert_eq!(refused, Err(RedactError::SharedJbig2Budget)); +} + +/// A run's budget is spent to the unit: a run may spend all of it, and is refused past it. What is +/// decoded spends a unit, and one more for each whole 4 KiB. +#[test] +fn a_runs_budget_is_spent_to_the_unit() { + let mut run = crate::globals::Run::new(10); + assert_eq!(run.decoded(4095), Ok(())); + assert_eq!(run.decoded(8 * 4096), Ok(())); + assert_eq!(run.decoded(0), Err(RedactError::SharedJbig2Budget)); +} + +/// What a stream's size, filters and parameters lead to is fetched once a run, however many streams +/// share them by reference: fifty images naming the same objects fetch each once for each way it is +/// read. +#[test] +fn what_streams_share_is_fetched_once_a_run() { + let mut objects = + vec![b"<< /Type /Catalog >>".to_vec(), b"/FlateDecode".to_vec(), b"<< /Predictor 4 0 R /Columns 4 0 R >>".to_vec(), b"1".to_vec()]; + objects.extend( + (0..50).map(|_| stream("/Width 4 0 R /Height 4 0 R /Filter [2 0 R 2 0 R] /DecodeParms [3 0 R 3 0 R] /ColorSpace 2 0 R /F 4 0 R", b"")), + ); + let doc = pdf(objects); + let mut run = crate::globals::Run::new(crate::globals::RUN_BUDGET); + for n in 5..55 { + let Object::Stream(s) = &*doc.get(ObjRef::new(n, 0)) else { panic!("object {n} is a stream") }; + let made = run.readable(&doc, s).unwrap(); + assert_eq!(made.dict.get(b"Width"), Some(&Object::Int(1))); + assert_eq!(made.dict.get(b"Filter").and_then(Object::as_array).map(Vec::len), Some(2)); + } + // Object 4 as a size and as a parameter, 2 as a filter, 3 as an entry. + assert_eq!(run.fetched, 4); +} + +/// A copy of the dictionary stored with a predictor is found whatever way its parameters are given, +/// through a chain of references among them, as a reader that follows them decodes it. Through a +/// chain the run either stores the image again with the copy's symbols, now that a reference to a +/// reference reads as what it names (#5548), or is refused; it never goes on with the copy left in +/// the file. +#[test] +fn a_copy_is_found_however_its_parameters_are_given() { + let scan = shared_scan(); + let globals = [jbig2_globals(b"LEAK_GLOBALS"), scan.symbols.clone()].concat(); + // PNG rows of one byte, each with no prediction: a byte for the row's tag, then the byte. + let predicted: Vec = globals.iter().flat_map(|b| [0u8, *b]).collect(); + let flate = pdfcraft_filters::encode_flate(&predicted); + let parms = b"<< /Predictor 12 /Columns 1 >>".to_vec(); + for (spelling, extra, stored) in [ + ("/DecodeParms << /Predictor 12 /Columns 1 >>", vec![], true), + ("/DecodeParms 11 0 R", vec![parms.clone()], true), + ("/DecodeParms 11 0 R", vec![b"12 0 R".to_vec(), parms.clone()], false), + ("/DecodeParms [11 0 R]", vec![b"12 0 R".to_vec(), parms.clone()], false), + ("/DecodeParms << /Predictor 11 0 R /Columns 1 >>", vec![b"12 0 R".to_vec(), b"12".to_vec()], false), + // `false` above: stored again or refused, either is safe (see the doc). + ] { + let mut objects = vec![stream(&format!("/Filter /FlateDecode {spelling}"), flate.clone())]; + objects.extend(extra.clone()); + let other = shared_image("/DecodeParms << /JBIG2Globals 10 0 R >>"); + let mut doc = SharedPages { other, extra: objects, ..SharedPages::new(&scan) }.build(&scan); + mark(&mut doc, 0, &[CLEARS], ""); + let at = format!("{spelling} {}", extra.len()); + match apply(&mut doc, None) { + Ok(report) => { + assert_eq!(report.images_restored, 1, "{at}"); + let kept = pdfcraft_cos::reachable_objects(&doc).unwrap(); + assert!(!kept.contains(&10) && !kept.contains(&9), "{at}: the copy or the dictionary stays"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits, "{at}"); + } + Err(e) => assert!(!stored && matches!(e, RedactError::SharedJbig2Globals(1)), "{at}: {e:?}"), + } + } +} + +/// An image that names the dictionary and another one is stored again without both, but the other +/// was shared with nothing that left: an image that uses only it stays as it was, whichever of the +/// two comes first in the file, and so does that dictionary. +#[test] +fn another_dictionary_named_beside_it_stays_whatever_the_order() { + let scan = shared_scan(); + let other_dictionary = stream("", [jbig2_globals(b"OTHER_GLOBALS"), scan.symbols.clone()].concat()); + let only_other = shared_image("/DecodeParms << /JBIG2Globals 11 0 R >>"); + let chained = "/Type /XObject /Subtype /Image /Width 400 /Height 40 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter [/FlateDecode /JBIG2Decode] /DecodeParms [<< /JBIG2Globals 11 0 R >> << /JBIG2Globals 9 0 R >>]"; + let both_spellings = [ + (chained.to_string(), pdfcraft_filters::encode_flate(&scan.page)), + (format!("{} /DP << /JBIG2Globals 11 0 R >>", shared_image(SHARED)), scan.page.clone()), + ]; + let other_stays = |doc: &Document| { + let saved = reopen(doc); + saved.scan_objects().any(|(_, o)| matches!(&*o, Object::Stream(s) if s.raw.windows(13).any(|w| w == b"OTHER_GLOBALS"))) + }; + for (both, data) in both_spellings { + // Image 8 is drawn on the second page, and image 10 is its alternate. + let only_other_first = SharedPages { + other: format!("{only_other} /Alternates [<< /Image 10 0 R >>]"), + extra: vec![stream(&both, data.clone()), other_dictionary.clone()], + ..SharedPages::new(&scan) + }; + let both_first = SharedPages { + other: format!("{both} /Alternates [<< /Image 10 0 R >>]"), + other_data: data.clone(), + extra: vec![stream(&only_other, scan.page.clone()), other_dictionary.clone()], + ..SharedPages::new(&scan) + }; + for (pages, only_other_at) in [(only_other_first, 8), (both_first, 10)] { + let mut doc = pages.build(&scan); + let untouched = doc.get(pdfcraft_cos::ObjRef::new(only_other_at, 0)); + mark(&mut doc, 0, &[CLEARS], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_restored, 1, "{both}"); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert!(other_stays(&doc), "{both}"); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(only_other_at, 0)), untouched, "{both}"); + } + } +} + +/// Remove Hidden Information leaves the same way: comments go, with what their appearances drew. +#[test] +fn removing_hidden_information_takes_a_shared_jbig2_dictionary_too() { + let scan = shared_scan(); + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Im 7 0 R >> >>", DRAWS); + let annotation = b"<< /Type /Annot /Subtype /Stamp /Rect [0 100 200 120] /AP << /N 11 0 R >> >>".to_vec(); + let mut doc = SharedPages { + content: Vec::new(), + resources: String::new(), + page: "/Annots [10 0 R]".into(), + extra: vec![annotation, form], + ..SharedPages::new(&scan) + } + .build(&scan); + crate::sanitize::remove_hidden(&mut doc, &[crate::sanitize::Hidden::Comments]).unwrap(); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits); +} + +/// Remove Hidden Information refuses as a redaction does when the dictionary can't leave, but it has +/// changed the document by then, so the refusal doesn't say nothing was changed. +#[test] +fn a_refused_removal_of_hidden_information_doesnt_say_nothing_changed() { + let scan = shared_scan(); + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Im 7 0 R >> >>", DRAWS); + let annotation = b"<< /Type /Annot /Subtype /Stamp /Rect [0 100 200 120] /AP << /N 11 0 R >> >>".to_vec(); + let mut doc = SharedPages { + content: Vec::new(), + resources: String::new(), + page: "/Annots [10 0 R]".into(), + other: shared_image(SHARED).replace("/Filter /JBIG2Decode", "/Filter /FlateDecode"), + other_data: pdfcraft_filters::encode_flate(&scan.bits), + extra: vec![annotation, form], + ..SharedPages::new(&scan) + } + .build(&scan); + let annotations = |doc: &Document| crate::annots_of(doc, &pdfcraft_model::pages(doc)[0].dict).len(); + assert_eq!(annotations(&doc), 1); + let refused = crate::sanitize::remove_hidden(&mut doc, &[crate::sanitize::Hidden::Comments]); + assert!(matches!(refused, Err(RedactError::SharedJbig2Globals(1)))); + assert_eq!(annotations(&doc), 0, "the comment went before the refusal"); + let text = refused.unwrap_err().to_string(); + assert!(text.ends_with("this was refused") && !text.contains("nothing was changed"), "{text}"); +} + +/// Remove Hidden Information also unlinks an image its page lists but no longer draws (left there by +/// Delete Image, say): that is a way out too, so the dictionary goes with it. +#[test] +fn an_undrawn_image_unlinked_takes_a_shared_jbig2_dictionary_too() { + let scan = shared_scan(); + let mut doc = SharedPages { content: Vec::new(), ..SharedPages::new(&scan) }.build(&scan); + crate::sanitize::remove_hidden(&mut doc, &[crate::sanitize::Hidden::Comments]).unwrap(); + assert!(!crate::globals::kept(&doc).unwrap().contains(&7), "the image nothing drew is unlinked"); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits); +} + +/// A redaction also removes the catalog's XMP when it repeats the removed text (#2448): an image +/// only that packet still refers to leaves the file then, and the dictionary with it. +#[test] +fn an_image_only_the_scrubbed_metadata_refers_to_takes_a_shared_jbig2_dictionary_too() { + let scan = shared_scan(); + let mut doc = SharedPages { + content: [DRAWS, b" BT /F1 10 Tf 10 105 Td (Bluefin) Tj ET".as_slice()].concat(), + resources: "/XObject << /Im 7 0 R >> /Font << /F1 11 0 R >>".into(), + extra: vec![ + stream("/Type /Metadata /Subtype /XML /Thumb 7 0 R", b"Bluefin"), + FONT.replace("95 0 R", "12 0 R").into_bytes(), + widths(), + ], + ..SharedPages::new(&scan) + } + .build(&scan); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| d.set(b"Metadata".to_vec(), Object::Ref(ObjRef::new(10, 0)))).unwrap(); + mark(&mut doc, 0, &[COVERS], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.metadata, report.images_restored), (1, 1), "the packet went, and the image on page 2 was stored again"); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits); +} + +/// A redaction that leaves a pattern unpainted unlinks it from a `/Pages` node no page inherits +/// from any more, here one the page's own parent shadowed: an image its cell drew leaves the file +/// then, and the dictionary with it. +#[test] +fn an_image_a_pruned_patterns_cell_drew_takes_a_shared_jbig2_dictionary_too() { + let scan = shared_scan(); + let cell = "/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 300 300] /XStep 300 /YStep 300 /Resources << /XObject << /Im 7 0 R >> >>"; + let image = shared_image(SHARED); + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [11 0 R 4 0 R] /Count 2 /Resources << /Pattern << /P0 10 0 R >> >> >>".to_vec(), + b"<< /Type /Page /Parent 11 0 R /MediaBox [0 0 300 300] /Contents 5 0 R >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Resources << /XObject << /Im 8 0 R >> >> >>".to_vec(), + stream("", b"/Pattern cs /P0 scn 0 100 200 20 re f"), + stream("", DRAWS), + stream(&image, &scan.page), + stream(&image, &scan.page), + stream("", [jbig2_globals(b"LEAK_GLOBALS"), scan.symbols.clone()].concat()), + stream(cell, DRAWS), + b"<< /Type /Pages /Parent 2 0 R /Kids [3 0 R] /Count 1 /Resources << /Pattern << /P0 10 0 R >> >> >>".to_vec(), + ]); + mark(&mut doc, 0, &[[-5.0, 95.0, 205.0, 125.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.patterns_removed, report.images_restored), (1, 1)); + assert!(!crate::globals::kept(&doc).unwrap().contains(&10), "the cell is unlinked"); + assert_gone(&doc, b"LEAK_GLOBALS"); + assert_eq!(drawn_samples(&reopen(&doc), 1), scan.bits); +} + +/// [`assert_no_secret`] for a file that may hold JBIG2 streams, which `decoded` doesn't read: a stream +/// stored again can be coded alone. +fn assert_gone(doc: &Document, secret: &[u8]) { + for (r, object) in reopen(doc).scan_objects() { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&object, &mut bytes); + assert!(!bytes.windows(secret.len()).any(|w| w == secret), "secret remains in object {r:?}"); + if let Object::Stream(s) = &*object + && let Ok(data) = s.decoded() + { + assert!(!data.windows(secret.len()).any(|w| w == secret), "secret remains in stream {r:?}"); + } + } +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. @@ -2117,6 +3197,479 @@ fn an_image_a_form_drew_through_the_pages_resources_does_not_survive() { assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "the image the form drew survived the save"); } +/// How a form can have no resources of its own: no /Resources, a null one, or a reference to an +/// object that isn't there (which is null too). +const NO_RESOURCES: [&str; 3] = ["", "/Resources null", "/Resources 99 0 R"]; + +/// The page draws an image under the mark and, outside it, a form without /Resources of its own +/// that draws the same image through the page's: the page keeps the name, whether the form lies +/// wholly outside the mark or is entered and left as it was. +#[test] +fn an_image_a_form_draws_again_through_the_pages_resources_is_kept() { + for bbox in ["[0 0 100 100]", "[0 0 300 300]"] { + for none in NO_RESOURCES { + let mut doc = one_page( + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + "/XObject << /FmBare 7 0 R /Im1 8 0 R >>", + vec![ + stream(&format!("/Type /XObject /Subtype /Form /BBox {bbox} {none}"), b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.forms_rewritten), (1, 0), "{bbox} {none}: {r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(content(&saved, 0).contains("/FmBare Do"), "{bbox} {none}"); + assert_eq!(named_image(&saved, 0, b"Im1").as_deref(), Some(&b"PUBIMGSHOWNAGAIN"[..]), "{bbox} {none}: the form still draws it"); + } + } +} + +/// The same one level down: a form with resources of its own draws the image under the mark and, +/// outside it, a form without any that draws it again through the first form's. +#[test] +fn an_image_a_nested_form_draws_again_through_its_callers_resources_is_kept() { + for none in NO_RESOURCES { + let mut doc = one_page( + b"/FmOwn Do", + "/XObject << /FmOwn 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /FmBare 8 0 R /Im1 9 0 R >> >>", + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + ), + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] {none}"), b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.forms_rewritten), (1, 1), "{none}: {r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(content(&saved, 0).contains("/PCRedacted1 Do"), "{none}"); + let form = named_stream(&saved, 0, b"PCRedacted1").expect("the rewritten form"); + assert!(form.decoded().unwrap().windows(10).any(|w| w == b"/FmBare Do"), "{none}"); + let res = saved.resolve(form.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = saved.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let image = xo.get(b"Im1").and_then(Object::as_ref).unwrap_or_else(|| panic!("{none}: the rewritten form still names /Im1")); + let Object::Stream(image) = &*saved.get(image) else { panic!("/Im1 is a stream") }; + assert_eq!(decoded_image(&saved, image).unwrap(), b"PUBIMGSHOWNAGAIN", "{none}"); + } +} + +/// A form without /Resources that won't decode may draw the image under the mark through the +/// page's: the redaction is refused. A page with fonts or properties to clean up refused it +/// already; this one has neither. +#[test] +fn a_form_that_cannot_be_read_refuses_the_redaction_of_an_image_it_may_draw() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /FmBroken 5 0 R /Im1 6 0 R >> >> >>" + .to_vec(), + stream("", b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBroken Do"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Filter /DCTDecode", b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ]); + let Object::Stream(broken) = &*doc.get(ObjRef::new(5, 0)) else { panic!("the form is a stream") }; + assert!(broken.decoded().is_err(), "the fixture's form must not decode"); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::Cos(pdfcraft_cos::CosError::Filter(_))))); +} + +/// A page of two content streams, with no fonts or properties: the first draws an image at +/// (20, 230), 50 pt square, named `image` in the page's resources; the second is `tail`, which +/// draws nothing. `catalog` goes in the catalog, and `image_dict` in the image's dictionary. +fn image_and_a_tail(catalog: &str, image: &str, image_dict: &str, tail: &[u8]) -> Document { + pdf(vec![ + format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>").into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents [4 0 R 5 0 R] /Resources << /XObject << /{image} 6 0 R >> >> >>") + .into_bytes(), + stream("", format!("q 50 0 0 50 20 230 cm /{image} Do Q").as_bytes()), + stream("", tail), + stream( + &format!("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 {image_dict}"), + b"REDIMGONECOVERED", + ), + b"<< /Type /OCG /Name (Secret layer) >>".to_vec(), + ]) +} + +/// The refusal of the check of what content still draws through its resources (`resource_ops`). +fn unread_content_refusal(result: &Result) -> bool { + matches!(result, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { detail, .. })) + if detail == "redaction could not fully read the content that uses these resources") +} + +/// What the page draws can't be told when a content stream the redaction leaves as it was holds +/// bytes the parser can't read: the redaction of an image is refused, as it was already on a page +/// with fonts or properties to clean up. +#[test] +fn unreadable_bytes_in_a_stream_left_as_it_was_refuse_the_redaction_of_an_image() { + for (tail, refused) in [(&b"q Q )"[..], true), (b"q Q", false)] { + let mut doc = image_and_a_tail("", "Im1", "", tail); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let result = apply(&mut doc, None); + if refused { + assert!(unread_content_refusal(&result), "{result:?}"); + } else { + assert_eq!(result.map(|r| r.images_removed).ok(), Some(1)); + } + } +} + +/// Remove Hidden Information makes the same check: removing an off layer's image is refused on a +/// page whose drawing can't be told. +#[test] +fn unreadable_bytes_in_a_stream_left_as_it_was_refuse_removing_a_hidden_image() { + use crate::sanitize::{Hidden, remove_hidden}; + for (tail, refused) in [(&b"q Q )"[..], true), (b"q Q", false)] { + let mut doc = image_and_a_tail("/OCProperties << /OCGs [7 0 R] /D << /OFF [7 0 R] >> >>", "ImOC", "/OC 7 0 R", tail); + let result = remove_hidden(&mut doc, &[Hidden::HiddenLayers]); + if refused { + assert!(unread_content_refusal(&result), "{result:?}"); + } else { + assert!(result.unwrap().iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0)); + } + } +} + +/// Remove Hidden Information keeps a name that a form without resources still draws, too. Hidden +/// text includes text wholly off the page, so what it removes from a form depends on where it is +/// drawn: the page's /Fm, drawn off the page, is rewritten, while /G draws /Fm again on the page, +/// where its text shows, and stays as it was. The page still names /Fm, with its text. +#[test] +fn removing_hidden_text_keeps_a_form_a_form_without_resources_still_draws() { + use crate::sanitize::{Hidden, remove_hidden}; + for none in NO_RESOURCES { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Fm 5 0 R /G 6 0 R >> >> >>".to_vec(), + stream("", b"q 1 0 0 1 1000 0 cm /Fm Do Q /G Do"), + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 7 0 R >> >>", + b"BT /F1 10 Tf 20 20 Td (KEEPTEXT) Tj ET", + ), + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] {none}"), b"/Fm Do"), + FONT.replace("95 0 R", "8 0 R").into_bytes(), + widths(), + ]); + let removed = remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap(); + assert!(removed.iter().any(|(h, n)| *h == Hidden::HiddenText && *n > 0), "{none:?}: nothing was removed"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(content(&saved, 0).contains("/G Do"), "{none:?}: /G was rewritten"); + let fm = named_stream(&saved, 0, b"Fm").and_then(|s| s.decoded().ok()); + assert!(fm.is_some_and(|d| d.windows(8).any(|w| w == b"KEEPTEXT")), "{none:?}: the page lost /Fm, which /G still draws"); + } +} + +/// The page's own check follows only forms without resources of their own: a form that names a +/// different image /Im1 in resources of its own doesn't keep the page's redacted /Im1. +#[test] +fn the_pages_check_does_not_follow_a_form_with_resources_of_its_own() { + let image = "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + let doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [] /Count 0 >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /Im1 4 0 R >> >>", b"/Im1 Do"), + stream(image, b"o"), + stream(image, b"p"), + ]); + let mut xo = Dict::from_iter([(b"FmOwn".to_vec(), Object::Ref(ObjRef::new(3, 0))), (b"Im1".to_vec(), Object::Ref(ObjRef::new(5, 0)))]); + crate::interp::prune(&doc, &mut pdfcraft_cos::DecodeBudget::page(), &mut xo, &[b"Im1".to_vec()], &[b"/FmOwn Do".to_vec()]).unwrap(); + assert!(!xo.contains(b"Im1") && xo.contains(b"FmOwn"), "{xo:?}"); +} + +/// What content draws through its resources is read up to 2,000,000 operators, those of the forms +/// it draws without resources of their own included; past that it is refused. +#[test] +fn what_content_draws_through_its_resources_is_read_up_to_two_million_operators() { + let doc = Document::new_empty(); + for (ops, read) in [(2_000_000, true), (2_000_001, false)] { + let content = b"n ".repeat(ops); + assert_eq!( + crate::interp::resource_ops(&doc, &mut pdfcraft_cos::DecodeBudget::page(), &Dict::new(), &[content]).is_ok(), + read, + "{ops} operators" + ); + } + // The content's own `/Fm Do` is one of them. + for (ops, read) in [(1_999_999, true), (2_000_000, false)] { + let doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [] /Count 0 >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1]", b"n ".repeat(ops)), + ]); + let res = Dict::from_iter([(b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm".to_vec(), Object::Ref(ObjRef::new(3, 0)))])))]); + assert_eq!( + crate::interp::resource_ops(&doc, &mut pdfcraft_cos::DecodeBudget::page(), &res, &[b"/Fm Do".to_vec()]).is_ok(), + read, + "a form of {ops} operators" + ); + } +} + +/// `content` behind a PNG predictor (Up, one column) whose `/DecodeParms` are given by reference, +/// in a stream with `entries` too: read without the parameters, its bytes are the predictor's tags +/// and differences, not the operators. +fn behind_parms_by_reference(doc: &mut Document, entries: &[(&str, Object)], content: &[u8]) -> Stream { + let mut rows = Vec::new(); + let mut above = 0u8; + for &b in content { + rows.extend([2, b.wrapping_sub(above)]); + above = b; + } + let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(12))]))); + let mut s = Stream::flate(Dict::new(), &rows); + for (key, value) in entries { + s.dict.set(key.as_bytes().to_vec(), value.clone()); + } + s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + s +} + +/// A form's entries: `/Subtype /Form` and `bbox`. +fn form_entries(bbox: [i64; 4]) -> Vec<(&'static str, Object)> { + vec![("Subtype", Object::name("Form")), ("BBox", Object::Array(bbox.into_iter().map(Object::Int).collect()))] +} + +/// A form outside the mark draws the image again through the page's resources, its content behind +/// a predictor whose `/DecodeParms` are given by reference. Both checks of whether the page still +/// draws it read the form through them, the page's (`resource_ops`) and the unlinking one at the +/// end (`names_content_draws`): the page keeps the image for the form. +#[test] +fn an_image_a_form_with_parameters_by_reference_draws_again_is_kept() { + let mut doc = one_page( + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + "/XObject << /FmBare 7 0 R /Im1 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b""), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + let form = behind_parms_by_reference(&mut doc, &form_entries([0, 0, 100, 100]), b"q 50 0 0 50 20 20 cm /Im1 Do Q"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.images_removed, 1, "{r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"Im1").as_deref(), Some(&b"PUBIMGSHOWNAGAIN"[..]), "the form still draws it"); +} + +/// A surviving form outside the mark shares the page's resources and draws the image itself, its +/// content behind a predictor whose `/DecodeParms` are given by reference: unlinking the redacted +/// image from the forms' resources reads it through them, and keeps the image the form draws. +#[test] +fn an_image_a_surviving_form_with_parameters_by_reference_draws_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + b"<< /XObject << /Im 6 0 R /Fm 7 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [150 150 300 300]", b""), + ]); + let mut entries = form_entries([150, 150, 300, 300]); + entries.push(("Resources", Object::Ref(ObjRef::new(5, 0)))); + let form = behind_parms_by_reference(&mut doc, &entries, b"q 50 0 0 50 200 200 cm /Im Do Q"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(surviving_references::full_save_holds(&doc, b"IMAGESECRET"), "the image the form still draws outside the mark"); +} + +/// A form without resources of its own crosses the mark and draws an image, or text, under it, +/// its content behind a predictor whose `/DecodeParms` are given by reference. The walk reads it +/// through them and removes what lies under the mark: the image, whether the page draws it under +/// the mark too or only the form does, and the text's glyphs. Read without them, the form looked +/// empty and was left as it was, still drawing them, and the page's check, which reads it with +/// them, kept the image's name for it. +#[test] +fn what_a_form_with_parameters_by_reference_draws_under_the_mark_is_removed() { + let mut doc = one_page(b"/FmBare Do", "/XObject << /FmBare 7 0 R >>", vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b"")]); + let mut entries = form_entries([0, 0, 100, 100]); + // Its drawing moved up by 210: the text lands at y 250, under the mark. + entries.push(("Matrix", Object::Array([1, 0, 0, 1, 0, 210].into_iter().map(Object::Int).collect()))); + let form = behind_parms_by_reference(&mut doc, &entries, b"BT /F1 10 Tf 20 40 Td (SECRETVALUE) Tj ET"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.forms_rewritten), (11, 1), "the text the form draws under the mark: {r:?}"); + for page in [&b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do"[..], b"/FmBare Do"] { + let mut doc = one_page( + page, + "/XObject << /FmBare 7 0 R /Im1 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b""), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"REDIMGONECOVERED"), + ], + ); + let mut entries = form_entries([0, 0, 100, 100]); + // Its drawing moved up by 210: the image lands at y 230 to 280, under the mark. + entries.push(("Matrix", Object::Array([1, 0, 0, 1, 0, 210].into_iter().map(Object::Int).collect()))); + let form = behind_parms_by_reference(&mut doc, &entries, b"q 50 0 0 50 20 20 cm /Im1 Do Q"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.forms_rewritten, 1, "{}: {r:?}", String::from_utf8_lossy(page)); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "{}: the image the form drew under the mark", String::from_utf8_lossy(page)); + } +} + +/// A form without resources of its own that the redaction rewrites gets a new name. It keeps none +/// of its own either when its copy of the page's loses nothing, which takes a form drawing itself +/// (here off the page, so outside the mark), and then goes on drawing through the page's: itself +/// and, through a form it draws unchanged, the image under the mark again outside it. The page's +/// check of what it still draws follows the rewritten form under its new name, so the page keeps +/// both names for it. +#[test] +fn an_image_a_rewritten_form_draws_again_through_the_pages_resources_is_kept() { + let mut doc = one_page( + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + "/XObject << /FmBare 7 0 R /Inner 8 0 R /Im1 9 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300]", + b"BT /F1 10 Tf 20 250 Td (SECRET) Tj ET /Inner Do q 1 0 0 1 0 -1000 cm /FmBare Do Q", + ), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.forms_rewritten), (1, 1), "{r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let page = content(&saved, 0); + let drawn: Vec> = + pdfcraft_content::parse(page.as_bytes()).ops.iter().filter(|op| op.is("Do")).filter_map(|op| op.last_name().map(<[u8]>::to_vec)).collect(); + assert!(drawn.len() == 1 && drawn[0] != b"FmBare", "the form is drawn under its new name: {page}"); + let rewritten = named_stream(&saved, 0, &drawn[0]).unwrap(); + assert!(!rewritten.dict.contains(b"Resources"), "the rewritten form keeps no resources of its own"); + assert!(named_stream(&saved, 0, b"FmBare").is_some(), "the rewritten form still draws the original"); + assert_eq!(named_image(&saved, 0, b"Im1").as_deref(), Some(&b"PUBIMGSHOWNAGAIN"[..]), "the unchanged form still draws it"); +} + +/// The walk of what content draws through its resources charges the forms it reads to the +/// allowance it is given: a form decoding to more than is left is refused, one within it read. +#[test] +fn what_content_draws_through_its_resources_is_read_within_its_allowance() { + let doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [] /Count 0 >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1]", b"n ".repeat(1000)), + ]); + let res = Dict::from_iter([(b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm".to_vec(), Object::Ref(ObjRef::new(3, 0)))])))]); + for (allowance, read) in [(2000, true), (1999, false)] { + let mut budget = pdfcraft_cos::DecodeBudget::new(allowance, 0); + assert_eq!(crate::interp::resource_ops(&doc, &mut budget, &res, &[b"/Fm Do".to_vec()]).is_ok(), read, "{allowance} bytes"); + } +} + +/// The cleanups after the content walk spend what the walk left of the page's allowance, one +/// allowance between them. A form without resources of its own that crosses the mark is read by +/// the walk, then again by the check of what the page still draws and by the font cleanup: at +/// 100 MiB, three reads are more than the page's 256 MiB, and the redaction is refused; at +/// 50 MiB it goes on. +#[test] +fn the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance() { + for (mib, refused) in [(100, true), (50, false)] { + let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]); + let mut doc = one_page( + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + "/XObject << /FmBare 7 0 R /Im1 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"o"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB"); + } +} + +/// The cleanups of a form the redaction rewrites spend the page's allowance too. The rewritten +/// form has resources of its own, so the page's cleanups don't follow it, and draws through them a +/// form without any that crosses the mark: the walk reads that form, then the check of what the +/// rewritten form still draws and its font cleanup read it again. At 100 MiB, three reads are more +/// than the page's 256 MiB, and the redaction is refused; at 50 MiB it goes on. +#[test] +fn the_cleanups_of_a_rewritten_form_spend_the_pages_allowance() { + for (mib, refused) in [(100, true), (50, false)] { + let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]); + let mut doc = one_page( + b"/FmText Do", + "/XObject << /FmText 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /XObject << /FmBare 8 0 R >> >>", + b"BT /F1 10 Tf 20 250 Td (SECRET) Tj ET /FmBare Do", + ), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB"); + } +} + +/// Removing hidden text spends what its content walk left of the page's allowance too. The page +/// draws a form holding hidden text, which is rewritten, and a form without resources of its own: +/// the walk reads the second, then the check of what the page still draws and the font cleanup +/// read it again. At 100 MiB, three reads are more than the page's 256 MiB, and the removal is +/// refused; at 50 MiB it goes on. +#[test] +fn removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance() { + use crate::sanitize::{Hidden, remove_hidden}; + for (mib, refused) in [(100, true), (50, false)] { + let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]); + let mut doc = one_page( + b"/FmHidden Do /FmBare Do", + "/XObject << /FmHidden 7 0 R /FmBare 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 3 Tr 20 250 Td (HIDDEN) Tj ET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank), + ], + ); + assert_eq!(remove_hidden(&mut doc, &[Hidden::HiddenText]).is_err(), refused, "{mib} MiB"); + } +} + +/// A form with resources of its own draws through those, not its caller's: one on another page +/// that names a different image /Im1 doesn't keep the redacted /Im1 in the dictionary both pages +/// inherit. +#[test] +fn a_form_with_resources_of_its_own_does_not_keep_its_callers_image() { + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /Resources << /XObject << /Im1 7 0 R /FmOwn 8 0 R >> >> >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"q 50 0 0 50 20 230 cm /Im1 Do Q"), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R >>".to_vec(), + stream("", b"/FmOwn Do"), + stream(image, b"REDIMGONECOVERED"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Im1 9 0 R >> >>", b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream(image, b"PUBIMGSHOWNAGAIN"), + ]); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.images_removed, 1, "{r:?}"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "the redacted image survived the save"); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let form = named_stream(&saved, 1, b"FmOwn").expect("page 2 still draws its form"); + let res = saved.resolve(form.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = saved.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let Object::Stream(own) = &*saved.get(xo.get(b"Im1").and_then(Object::as_ref).unwrap()) else { panic!("/Im1 is a stream") }; + assert_eq!(decoded_image(&saved, own).unwrap(), b"PUBIMGSHOWNAGAIN", "the form's own image stays"); +} + /// Remove Hidden Information has the same rewrite: an off layer's image named by an inherited /// /Resources must leave the saved file too. #[test] @@ -4369,6 +5922,43 @@ fn untouched_replacement_descriptions_survive_redaction_and_hidden_text_cleanup( } } +/// An image drawn through an ICC profile has as many components as the profile's `/N` says, and +/// is cleared in place like any other, the profile given through a chain of references too. +#[test] +fn an_icc_based_image_is_cleared_in_place_through_any_references() { + for (n, chained) in [(1, false), (3, false), (1, true), (3, true)] { + let img = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/ICCBased 8 0 R] /BitsPerComponent 8", vec![255u8; 2 * n]); + let icc = stream(&format!("/N {n}"), b""); + let objects = if chained { vec![img, b"9 0 R".to_vec(), icc] } else { vec![img, icc] }; + let mut doc = one_page(b"q 20 0 0 10 100 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objects); + // The left pixel. + mark(&mut doc, 0, &[[95.0, 95.0, 109.0, 115.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.images_cleared), (0, 1), "/N {n}, chained {chained}: {r:?}"); + let samples = [vec![0u8; n], vec![255u8; n]].concat(); + assert_eq!(named_image(&doc, 0, b"PCRedacted1"), Some(samples), "/N {n}, chained {chained}"); + } +} + +/// A cleared copy keeps the parameters for data in another file only while it has one: `/FFilter` +/// and `/FDecodeParms` mean nothing without a `/F` that isn't null, and may name a JBIG2 +/// dictionary that would then stay. +#[test] +fn a_cleared_copy_keeps_parameters_for_data_in_another_file_only_with_one() { + for (f, kept) in [("/F (scan.jb2)", true), ("/F 9 0 R", true), ("/F null", false), ("/F 8 0 R", false), ("/F 99 0 R", false), ("", false)] { + let dict = format!( + "/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 {f} /FFilter /JBIG2Decode /FDecodeParms << /JBIG2Globals 10 0 R >>" + ); + let objects = vec![stream(&dict, [255u8, 255]), b"null".to_vec(), b"(scan.jb2)".to_vec()]; + let doc = one_page(b"q 20 0 0 10 100 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objects); + let Object::Stream(s) = &*doc.get(ObjRef::new(7, 0)) else { panic!("object 7 is the image") }; + let left = [[0.0, 0.0, 0.4, 1.0]]; + let copy = crate::image::clear(&doc, s, &pdfcraft_content::Matrix::IDENTITY, &left, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)); + let Ok(Some(copy)) = copy else { panic!("{f}: cleared in a copy") }; + assert_eq!((copy.dict.contains(b"FFilter"), copy.dict.contains(b"FDecodeParms")), (kept, kept), "{f}"); + } +} + #[test] fn a_hostile_icc_component_count_is_refused_not_multiplied_into_a_crash() { // /N comes from the file. 2^60 × 8 bpc × width 2 overflows the row size; a negative /N wrapped diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index d0be2a194a..0d171b4008 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -185,6 +185,73 @@ fn a_redacted_type3_glyphs_image_does_not_survive_in_a_surviving_glyphs_form() { assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "the image the surviving glyph's form draws was lost"); } +/// The same Form, its content behind a predictor whose `/DecodeParms` are given by reference: the +/// cleanup of its resources reads it through them, and keeps the image it draws. Read without +/// them, this content's bytes are all operators that use no resource, so nothing refuses and +/// the image would go. +#[test] +fn an_image_a_type3_glyphs_form_with_parameters_by_reference_draws_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> /Resources << /XObject << /ImA 9 0 R /Fm 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do"), + stream("", b"/Fm Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000]", b""), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBLIC_IMAGE_B"), + ]); + let mut entries = super::form_entries([0, 0, 1000, 1000]); + entries.push(( + "Resources", + Object::Dict(Dict::from_iter([( + b"XObject".to_vec(), + Object::Dict(Dict::from_iter([(b"ImA".to_vec(), Object::Ref(ObjRef::new(9, 0))), (b"ImB".to_vec(), Object::Ref(ObjRef::new(11, 0)))])), + )])), + )); + let form = super::behind_parms_by_reference(&mut doc, &entries, b"q 1000 0 0 1000 0 0 cm /ImB Do Q"); + doc.set(ObjRef::new(10, 0), form); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A"), "the removed glyph's image survives through the surviving glyph's form"); + assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "the image the surviving glyph's form draws was lost"); +} + +/// The Form a kept glyph draws is walked, for the names its content uses, within the allowance of +/// a page: two forms without resources of their own that it draws, each past half of it, refuse +/// the redaction rather than being decoded in full. Two small ones are the control. +#[test] +fn the_form_a_kept_type3_glyph_draws_is_walked_within_the_allowance_of_a_page() { + for (size, refused) in [(pdfcraft_cos::MAX_PAGE_DECODED / 2 + 1, true), (1, false)] { + let packed = pdfcraft_filters::encode_flate(&vec![b' '; size]); + let blank = || stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000] /Filter /FlateDecode", packed.as_slice()); + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> /Resources << /XObject << /ImA 9 0 R /Fm 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do"), + stream("", b"/Fm Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000] /Resources << /XObject << /Sub1 11 0 R /Sub2 12 0 R >> >>", b"/Sub1 Do /Sub2 Do"), + blank(), + blank(), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + let out = apply(&mut doc, None); + if refused { + assert!(matches!(out, Err(RedactError::Cos(pdfcraft_cos::CosError::Filter(_)))), "forms of {size} bytes each: {out:?}"); + } else { + assert_eq!(out.unwrap().glyphs, 1, "forms of {size} bytes each"); + } + } +} + /// When all glyphs of a Type 3 font are redacted, the entire font is removed from Resources (F-011). #[test] fn all_redacted_type3_glyphs_cause_font_removal_from_resources() { @@ -323,6 +390,139 @@ fn type3_font_used_in_a_form_xobject_has_its_redacted_glyph_removed() { assert!(!full_save_holds(&doc, b"SECRET_FORM_GLYPH_PAYLOAD"), "redacted glyph program survived"); } +/// A Type 3 font with glyphs A (its program object 7) and B (object 8), at object 6. +const TYPE3: &str = "<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> >>"; + +/// A form outside the mark shows glyph B of a Type 3 font, its content behind a predictor whose +/// `/DecodeParms` are given by reference: the font's cleanup reads it through them, and keeps B. +#[test] +fn a_type3_glyph_a_form_with_parameters_by_reference_shows_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET /Fm Do"), + b"<< /Font << /T3 6 0 R >> /XObject << /Fm 9 0 R >> >>".to_vec(), + TYPE3.as_bytes().to_vec(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"% PUBLIC_TYPE3_GLYPH_B"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 50]", b""), + ]); + let mut entries = super::form_entries([0, 0, 300, 50]); + entries.push(("Resources", Object::Ref(ObjRef::new(5, 0)))); + let form = super::behind_parms_by_reference(&mut doc, &entries, b"BT /T3 10 Tf 10 20 Td (B) Tj ET"); + doc.set(ObjRef::new(9, 0), form); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_TYPE3_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"PUBLIC_TYPE3_GLYPH_B"), "the form still shows B"); +} + +/// A form the page lists but doesn't draw shows glyph B, its content behind a predictor whose +/// `/DecodeParms` are given by reference: the font's cleanup reads every surviving form, this one +/// through them, and keeps B. +#[test] +fn a_type3_glyph_an_undrawn_form_with_parameters_by_reference_shows_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> /XObject << /Fm 9 0 R >> >>".to_vec(), + TYPE3.as_bytes().to_vec(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"% PUBLIC_TYPE3_GLYPH_B"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 50]", b""), + ]); + let mut entries = super::form_entries([0, 0, 300, 50]); + entries.push(( + "Resources", + Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"T3".to_vec(), Object::Ref(ObjRef::new(6, 0)))])))])), + )); + let form = super::behind_parms_by_reference(&mut doc, &entries, b"BT /T3 10 Tf 10 20 Td (B) Tj ET"); + doc.set(ObjRef::new(9, 0), form); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_TYPE3_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"PUBLIC_TYPE3_GLYPH_B"), "the undrawn form still shows B"); +} + +/// Glyph B's program draws an image from the font's resources, behind a predictor whose +/// `/DecodeParms` are given by reference: the font's cleanup reads it through them, and keeps the +/// image B still draws while the one only the redacted A drew goes. +#[test] +fn an_image_a_type3_glyph_with_parameters_by_reference_draws_is_kept() { + let image = "/Type /XObject /Subtype /Image /Width 12 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + let font = TYPE3.replace("/CharProcs", "/Resources << /XObject << /ImA 9 0 R /ImB 10 0 R >> >> /CharProcs"); + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj 0 100 Td (B) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + font.into_bytes(), + stream("", b"/ImA Do"), + stream("", b""), + stream(image, b"SECRET_IMG_A"), + stream(image, b"PUBLIC_IMG_B"), + ]); + let glyph = super::behind_parms_by_reference(&mut doc, &[], b"/ImB Do"); + doc.set(ObjRef::new(8, 0), glyph); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_IMG_A"), "the image only the redacted glyph drew survived"); + assert!(full_save_holds(&doc, b"PUBLIC_IMG_B"), "glyph B still draws its image"); +} + +/// Whether `out` is the redaction refused with a syntax error whose detail holds `detail`. +fn refused_for(out: &Result, detail: &str) -> bool { + matches!(out, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { detail: d, .. })) if d.contains(detail)) +} + +/// The font's cleanup scans every page and form for the glyphs they show. A string left open in a +/// form nothing else reads (resources of its own, outside the mark) runs to the end of the +/// stream, hiding the glyph the form shows after it, so the redaction is refused: with the check +/// off, that glyph loses its program. +#[test] +fn unreadable_bytes_in_a_form_the_type3_cleanup_scans_refuse_the_redaction() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET /Fm Do"), + b"<< /Font << /T3 6 0 R >> /XObject << /Fm 9 0 R >> >>".to_vec(), + TYPE3.as_bytes().to_vec(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"% PUBLIC_TYPE3_GLYPH_B"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 50] /Resources << /Font << /T3 6 0 R >> >>", b"BT /T3 10 Tf 10 20 Td ( (B) Tj ET"), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + let out = apply(&mut doc, None); + assert!(refused_for(&out, "content stream syntax"), "{out:?}"); +} + +/// The font's cleanup reads the program of each glyph it keeps, for the resources it uses. A +/// string left open there runs to the end of the program, hiding the image it draws after it, so +/// the redaction is refused: with the check off, the font's resources lose that image. +#[test] +fn unreadable_bytes_in_a_kept_type3_glyph_program_refuse_the_redaction() { + let font = TYPE3.replace("/CharProcs", "/Resources << /XObject << /ImB 9 0 R >> >> /CharProcs"); + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj 0 100 Td (B) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + font.into_bytes(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"500 0 d0 ( /ImB Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"o"), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + let out = apply(&mut doc, None); + assert!(refused_for(&out, "glyph stream syntax"), "{out:?}"); +} + /// A Form the surviving glyph draws holds `/Resources` as an array that references the removed /// glyph's image: it can't be pruned, so the redaction refuses rather than keep the image. #[test] @@ -430,6 +630,36 @@ fn a_type3_font_only_an_appearance_without_resources_selects_stays_on_the_page() assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the appearance selects"); } +/// The same appearance, its content behind a predictor whose `/DecodeParms` are given by +/// reference: the page's cleanup reads it through them, and keeps the font it selects. +#[test] +fn a_type3_font_only_an_appearance_with_parameters_by_reference_selects_stays_on_the_page() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R /Annots [8 0 R] >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET"), + b"<< /Font << /T3 6 0 R /T4 7 0 R >> >>".to_vec(), + type3_abc(10), + type3_abc(13), + b"<< /Type /Annot /Subtype /FreeText /Rect [150 200 250 220] /P 3 0 R /DA (/T4 10 Tf 0 g) /AP << /N 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20]", b""), + stream("", b"% REDACTED_GLYPH_A"), + stream("", b"% UNUSED_GLYPH_B"), + stream("", b"% PAGE_GLYPH_C"), + stream("", b"% UNUSED_T4_GLYPH_A"), + stream("", b"% APPEARANCE_GLYPH_B"), + stream("", b"% UNUSED_T4_GLYPH_C"), + ]); + let appearance = super::behind_parms_by_reference(&mut doc, &super::form_entries([0, 0, 100, 20]), b"BT /T4 10 Tf 2 5 Td (B) Tj ET"); + doc.set(ObjRef::new(9, 0), appearance); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"REDACTED_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"APPEARANCE_GLYPH_B"), "the glyph the appearance shows was unlinked"); + assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the appearance selects"); +} + /// The operators of a page's appearances count toward one total, as the content's do. Two /// appearances under the cap alone pass it together, and the redaction is refused rather than /// judged on part of what draws through the page's fonts. @@ -483,6 +713,35 @@ fn a_page_s_appearances_share_one_decoded_byte_allowance() { assert!(refused.is_err(), "two appearances past the page's decoded-byte allowance together were read in full: {refused:?}"); } +/// The appearances are read within what the page's content left of the page's allowance, not +/// within one of their own: 1 MiB of content and an appearance within half a MiB of the allowance +/// together pass it, though the appearance alone fits. A small appearance is the control. +#[test] +fn a_page_s_appearances_spend_what_its_content_left_of_the_allowance() { + let mut content = b"BT /T3 10 Tf 10 100 Td (AC) Tj ET".to_vec(); + content.resize(1 << 20, b' '); + let content = pdfcraft_filters::encode_flate(&content); + for (size, refused) in [(pdfcraft_cos::MAX_PAGE_DECODED - (1 << 19), true), (1, false)] { + let appearance = pdfcraft_filters::encode_flate(&vec![b' '; size]); + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R /Annots [7 0 R] >>".to_vec(), + stream("/Filter /FlateDecode", content.as_slice()), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + type3_abc(9), + b"<< /Type /Annot /Subtype /FreeText /Rect [150 200 250 220] /P 3 0 R /AP << /N 8 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20] /Filter /FlateDecode", appearance.as_slice()), + stream("", b"% GLYPH_A"), + stream("", b"% GLYPH_B"), + stream("", b"% GLYPH_C"), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + let out = apply(&mut doc, None); + assert_eq!(out.is_err(), refused, "an appearance of {size} bytes: {out:?}"); + } +} + /// Whether the first page of a full save of `doc` lists the font `name` in its resources. fn saved_first_page_lists_font(doc: &Document, name: &[u8]) -> bool { let bytes = write_full(doc, &SaveOptions::default()).unwrap(); @@ -524,6 +783,41 @@ fn a_type3_font_only_a_soft_mask_without_resources_selects_stays_on_the_page() { assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the soft mask selects"); } +/// The same group, its content behind a predictor whose `/DecodeParms` are given by reference: +/// the page's cleanup reads it through them, and keeps the font it selects. +#[test] +fn a_type3_font_only_a_soft_mask_with_parameters_by_reference_selects_stays_on_the_page() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET /GS1 gs 150 150 100 100 re f"), + b"<< /Font << /T3 6 0 R /T4 7 0 R >> /ExtGState << /GS1 << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 8 0 R >> >> >> >>" + .to_vec(), + type3_abc(9), + type3_abc(12), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b""), + stream("", b"% REDACTED_GLYPH_A"), + stream("", b"% UNUSED_GLYPH_B"), + stream("", b"% PAGE_GLYPH_C"), + stream("", b"% UNUSED_T4_GLYPH_A"), + stream("", b"% MASK_GLYPH_B"), + stream("", b"% UNUSED_T4_GLYPH_C"), + ]); + let mut entries = super::form_entries([0, 0, 300, 300]); + entries.push(( + "Group", + Object::Dict(Dict::from_iter([(b"S".to_vec(), Object::name("Transparency")), (b"CS".to_vec(), Object::name("DeviceGray"))])), + )); + let group = super::behind_parms_by_reference(&mut doc, &entries, b"1 g BT /T4 40 Tf 160 180 Td (B) Tj ET"); + doc.set(ObjRef::new(8, 0), group); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"REDACTED_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"MASK_GLYPH_B"), "the glyph the soft mask shows was unlinked"); + assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the soft mask selects"); +} + /// The cell of a tiling pattern another page fills with shows glyph B: its program is kept. (On /// the redacted page itself, a pattern that shows text refuses the redaction: `pattern_guard`.) #[test] diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 7347bfdb82..4c3f2211e8 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -131,11 +131,13 @@ struct UsedInGlyphs { } fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Result<(), CosError> { - let parsed = pdfcraft_content::parse(data); - if parsed.skipped > 0 { + // One operator at a time, so a glyph program of millions of them is never held whole. A byte + // that can't be read anywhere in it still refuses, once the loop has read it all. + let mut read = pdfcraft_content::ops(data); + read.by_ref().for_each(|op| record_used(&op, used)); + if read.skipped() > 0 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect glyph stream syntax".into() }); } - parsed.ops.iter().for_each(|op| record_used(op, used)); Ok(()) } @@ -218,9 +220,13 @@ fn prune_font_forms(doc: &mut Document, font_res: &Dict) -> Result<(), CosError> } }, }; - let data = form.decoded()?; + let data = doc.with_direct_parms(form).decoded()?; let mut used = UsedInGlyphs::default(); - crate::interp::resource_ops(doc, &res, std::slice::from_ref(&data))?.iter().for_each(|op| record_used(op, &mut used)); + // Each Form's walk has the allowance of a page, as `prune_retired_properties` gives each + // content it reads. + crate::interp::resource_ops(doc, &mut pdfcraft_cos::DecodeBudget::page(), &res, std::slice::from_ref(&data))? + .iter() + .for_each(|op| record_used(op, &mut used)); // `rg`, `g` and `k` draw through these entries when the Form keeps them as its defaults, // and nothing cites them with `cs`/`CS`: pruning them would change the Form's colours. used.colorspaces.extend([b"DefaultRGB".to_vec(), b"DefaultGray".to_vec(), b"DefaultCMYK".to_vec()]); @@ -287,15 +293,14 @@ fn scan_content_stream( return Err(CosError::Syntax { offset: 0, detail: "redaction exceeded max depth inspecting Type 3 font content".into() }); } let joined = Pieces::join(content); - let parsed = joined.parse(); - if parsed.skipped > 0 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect content stream syntax".into() }); - } + // One operator at a time, so content of millions of them is never held whole. The scan only + // reads, and a byte that can't be read anywhere still refuses, once the loop has read it all. + let mut read = pdfcraft_content::ops(joined.data()); let mut stack: Vec> = Vec::new(); let mut current_font = initial_font; - for op in parsed.ops { + for op in read.by_ref() { match op.op.as_slice() { b"q" => stack.push(current_font), b"Q" => { @@ -322,6 +327,9 @@ fn scan_content_stream( _ => apply_text_op(ctx, res, &op, &mut current_font), } } + if read.skipped() > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect content stream syntax".into() }); + } Ok(()) } @@ -490,7 +498,7 @@ fn add_soft_mask_ops(doc: &Document, res: &Dict, ops: &mut Vec 0 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect soft mask content".into() }); } @@ -517,7 +525,7 @@ fn scan_stream_object( if !ctx.seen_forms.insert((r, current_font, through)) { return Ok(()); } - let data = s.decoded()?; + let data = ctx.doc.with_direct_parms(s).decoded()?; match own_res { Some(own) => scan_content_stream(ctx, std::slice::from_ref(&data), r, &own, current_font, depth + 1), None => scan_content_stream(ctx, std::slice::from_ref(&data), holder_ref, res, current_font, depth + 1), @@ -533,8 +541,15 @@ fn gs_font_object(doc: &Document, state_obj: &Object) -> Option { /// Prune Type 3 fonts from a rewritten page or form's `/Resources` dictionary if the rewritten /// content no longer draws any text using that font name. `page` is the page's dictionary when -/// `res` are a page's resources. -pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content: &[Vec], page: Option<&Dict>) -> Result<(), CosError> { +/// `res` are a page's resources. What it reads is charged to `budget`, what is left of the page's +/// allowance. +pub(crate) fn prune_type3_from_resources( + doc: &Document, + budget: &mut pdfcraft_cos::DecodeBudget, + res: &mut Dict, + content: &[Vec], + page: Option<&Dict>, +) -> Result<(), CosError> { let mut fonts = res_dict(doc, res, b"Font"); if fonts.is_empty() { return Ok(()); @@ -542,20 +557,20 @@ pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content // The operators of forms that draw through these resources count too: dropping a font a child // form still shows would leave its text without one. - let mut ops = crate::interp::resource_ops(doc, res, content)?; + let mut ops = crate::interp::resource_ops(doc, budget, res, content)?; // So do those of a page's annotation appearances, and of soft mask groups, without resources of // their own. They are read only when there is a Type 3 font to keep, so other files are not // refused over them. if fonts.iter().any(|(_, f)| doc.resolve(f).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Type3"))) { if let Some(page) = page { // One total for all of them, as for the content: each appearance under the cap could - // still add up to far more than it. Their decoded bytes share one page's allowance - // too, and each is decoded only in its turn, so they are never all held at once. - let mut budget = pdfcraft_cos::DecodeBudget::page(); + // still add up to far more than it. Their decoded bytes, and their forms', are charged to + // the page's allowance, which the content and its forms spend too, and each is decoded + // only in its turn, so they are never all held at once. for r in resourceless_appearances(doc, page) { let Object::Stream(s) = &*doc.get(r) else { continue }; - let data = budget.decode(s)?; - let more = crate::interp::resource_ops(doc, res, std::slice::from_ref(&data))?; + let data = budget.decode(&doc.with_direct_parms(s))?; + let more = crate::interp::resource_ops(doc, budget, res, std::slice::from_ref(&data))?; if ops.len().saturating_add(more.len()) > 2_000_000 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect annotation appearances".into() }); } @@ -696,7 +711,7 @@ pub(crate) fn unlink_type3(doc: &mut Document) -> Result<(), RedactError> { for (form_ref, form_res) in &form_holders { if !ctx.seen_forms.iter().any(|(form, _, _)| form == form_ref) { let Object::Stream(s) = &*ctx.doc.get(*form_ref) else { continue }; - let data = s.decoded()?; + let data = ctx.doc.with_direct_parms(s).decoded()?; scan_content_stream(&mut ctx, std::slice::from_ref(&data), *form_ref, form_res, None, 0)?; } } @@ -830,7 +845,7 @@ fn clean_type3_font( } .into()); }; - let data = s.decoded()?; + let data = doc.with_direct_parms(s).decoded()?; collect_used_resources_in_stream(&data, &mut used_in_glyphs)?; } diff --git a/crates/redact/tests/operator_memory.rs b/crates/redact/tests/operator_memory.rs new file mode 100644 index 0000000000..f360f83cdd --- /dev/null +++ b/crates/redact/tests/operator_memory.rs @@ -0,0 +1,142 @@ +//! Memory regressions for the streams a redaction reads besides its walk: the forms it follows +//! through resources, and what the Type 3 cleanup scans and reads. Their operators are read one +//! at a time, so a stream of millions of them is never held whole. A test binary of its own, so +//! that the one test is all the process runs while its peak is measured. + +use std::sync::Arc; + +use pdfcraft_annot::{Meta, NewAnnotation, Shape, Style, add_annotation, rect_quad}; +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_redact::apply; +use pdfcraft_testkit::{Pdf, stream_obj}; + +const IMAGE: &str = "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + +/// 32 MiB of `n` operators: 16 million of them, eight times the walk's limit. Held as parsed +/// operators, at about 120 bytes each, nearly 2 GiB. +fn operators() -> Vec { + b"n\n".repeat(16 << 20) +} + +/// `form` with `content`, compressed. +fn compress(doc: &mut Document, form: ObjRef, content: &[u8]) { + let Object::Stream(s) = &*doc.get(form) else { panic!("{form:?} is a stream") }; + let dict = s.dict.clone(); + doc.set(form, Object::Stream(Stream::flate(dict, content))); +} + +fn mark(doc: &mut Document) { + let shape = Shape::Redact { quads: vec![rect_quad([0.0, 200.0, 300.0, 300.0])], overlay: String::new(), look: Default::default() }; + let style = Style::default_for(&shape); + add_annotation(doc, &NewAnnotation { page: 0, shape, style, contents: String::new(), author: "Synthetic test".into() }, &Meta::default()) + .unwrap(); +} + +/// How much the process's peak resident memory grows, in MiB, while `f` runs. `None` where the +/// peak can't be reset (`/proc/self/clear_refs` is Linux's). +fn peak_growth(f: impl FnOnce() -> T) -> Option<(T, u64)> { + fn peak_kib() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + status.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.split_whitespace().next()?.parse().ok() + } + std::fs::write("/proc/self/clear_refs", "5").ok()?; + let before = peak_kib()?; + let out = f(); + Some((out, peak_kib()?.saturating_sub(before) >> 10)) +} + +/// A page that draws an image under the mark, a form `form` with resources of its own outside it, +/// and a glyph of a Type 3 font outside it, whose program is `glyph`: the Type 3 cleanup reads +/// every page and form, and the programs of the glyphs it keeps. +fn type3_page(form: &[u8], glyph: &[u8]) -> Document { + let mut doc = Document::open(Arc::new( + Pdf::new() + .objs(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /T3 7 0 R >> /XObject << /Im1 5 0 R /Fm 6 0 R >> >> >>" + .to_vec(), + stream_obj("", b"q 50 0 0 50 20 230 cm /Im1 Do Q /Fm Do BT /T3 10 Tf 10 10 Td (a) Tj ET"), + stream_obj(IMAGE, b"o"), + stream_obj("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << >>", b""), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] /CharProcs << /a 8 0 R >> /Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [1000] /Resources << >> >>" + .to_vec(), + stream_obj("", b""), + ]) + .build(), + )) + .unwrap(); + compress(&mut doc, ObjRef::new(6, 0), form); + compress(&mut doc, ObjRef::new(8, 0), glyph); + mark(&mut doc); + doc +} + +/// Four reads of a stream of 16 million operators: +/// - the page's check of what it still draws walks a form without resources of its own outside +/// the mark, and refuses at 2,000,001 operators; +/// - the unlinking check of a page nobody marked, which shares the marked page's resources, +/// looks through such a form for the image the marked page removed, and finds no `Do`; +/// - the Type 3 cleanup scans a form with resources of its own that nothing else reads, for the +/// glyphs it shows; +/// - the Type 3 cleanup reads the program of a glyph it keeps, for the resources it uses. +/// +/// Each parsed every operator first: the peak grew by 4,415, 2,526, 2,526 and 2,494 MiB. Now +/// the first holds at most one operator past the walk's limit, and the others none. +#[test] +fn the_forms_followed_through_resources_are_read_one_operator_at_a_time() { + // The page's check: an image under the mark, and the form outside it. + let mut doc = Document::open(Arc::new( + Pdf::new() + .objs(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R /Fm 6 0 R >> >> >>" + .to_vec(), + stream_obj("", b"q 50 0 0 50 20 230 cm /Im1 Do Q /Fm Do"), + stream_obj(IMAGE, b"o"), + stream_obj("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b""), + ]) + .build(), + )) + .unwrap(); + compress(&mut doc, ObjRef::new(6, 0), &operators()); + mark(&mut doc); + let Some((result, grew)) = peak_growth(|| apply(&mut doc, None)) else { return }; + assert!(result.is_err(), "the walk refuses past 2,000,000 operators: {result:?}"); + assert!(grew < 1024, "the page's check grew the peak by {grew} MiB"); + + // The unlinking check: two pages share the /Pages node's resources; the second draws the form. + let mut doc = Document::open(Arc::new( + Pdf::new() + .objs(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [6 0 R 7 0 R] /Count 2 /Resources << /XObject << /Im1 5 0 R /Fm 3 0 R >> >> >>".to_vec(), + stream_obj("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b""), + stream_obj("", b"q 50 0 0 50 20 230 cm /Im1 Do Q"), + stream_obj(IMAGE, b"o"), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 8 0 R >>".to_vec(), + stream_obj("", b"/Fm Do"), + ]) + .build(), + )) + .unwrap(); + compress(&mut doc, ObjRef::new(3, 0), &operators()); + mark(&mut doc); + let Some((result, grew)) = peak_growth(|| apply(&mut doc, Some(&[0]))) else { return }; + assert_eq!(result.map(|r| r.images_removed).ok(), Some(1), "the marked page's image goes"); + assert!(grew < 1024, "the unlinking check grew the peak by {grew} MiB"); + + let mut doc = type3_page(&operators(), b"1000 0 d0"); + let Some((result, grew)) = peak_growth(|| apply(&mut doc, None)) else { return }; + assert_eq!(result.map(|r| r.images_removed).ok(), Some(1), "the Type 3 scan: the image goes"); + assert!(grew < 1024, "the Type 3 cleanup's scan grew the peak by {grew} MiB"); + + let mut glyph = b"1000 0 d0\n".to_vec(); + glyph.extend(operators()); + let mut doc = type3_page(b"", &glyph); + let Some((result, grew)) = peak_growth(|| apply(&mut doc, None)) else { return }; + assert_eq!(result.map(|r| r.images_removed).ok(), Some(1), "the glyph program: the image goes"); + assert!(grew < 1024, "the Type 3 cleanup's read of a glyph program grew the peak by {grew} MiB"); +} diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 015f32449d..6689831df0 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -40,6 +40,7 @@ pub use model::{ MAX_GROUP_DEPTH, OutlineItem, PageInfo, PageTransition, Popup, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa, fill_group_text, group_primaries, link_replies, thread_tops, }; +pub use navigation::named_destination; #[cfg(test)] use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; #[cfg(test)] diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs index 4c6e88400e..c5f2a1be86 100644 --- a/crates/render/src/inspect/navigation.rs +++ b/crates/render/src/inspect/navigation.rs @@ -1,10 +1,10 @@ #[cfg(test)] use super::core::inspect; -use super::core::spend; +use super::core::{load_options, spend}; use super::model::{DestView, LinkTarget, OutlineItem, PageInfo}; use super::{ - BTreeMap, Dictionary, HashMap, HashSet, Inspector, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, - MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, alpha, roman, + Arc, AssertUnwindSafe, BTreeMap, Dictionary, Document, HashMap, HashSet, Inspector, LazyStructure, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, + MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, Structure, alpha, catch_unwind, roman, }; /// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document @@ -72,6 +72,17 @@ impl<'a> Inspector<'a> { catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok()) } + /// The destination whose key, read as a text string (UTF-16 or PDFDocEncoding), is `name`: + /// a name tree key, then a key of the PDF 1.1 `/Dests` dictionary, whose names older files + /// write in PDFDocEncoding rather than UTF-8. + fn named_dest_by_text(&self, name: &str) -> Option<&'a Object> { + if let Some((_, dest)) = self.named.iter().find(|(key, _)| text_string(key) == name) { + return Some(dest); + } + let dests = self.doc.catalog().ok()?.get(b"Dests").ok().and_then(|o| self.dict(o))?; + dests.iter().find(|(key, _)| text_string(key) == name).map(|(_, dest)| dest) + } + /// A name tree's leaves keyed by their raw key bytes (first occurrence wins). pub(super) fn name_tree_raw( &self, @@ -460,6 +471,37 @@ fn label_work(left: &mut usize, count: usize) -> Result<(), &'static str> { Ok(()) } +/// The (0-based page, view) of the named destination `name` (ISO 32000-2 §12.3.2.4): a key of +/// the `/Names /Dests` name tree or of the PDF 1.1 catalog `/Dests` dictionary, as a link's or +/// `/OpenAction`'s name resolves. `None` when the document can't be read, has no such name, or +/// the name leads to no page of it. A key matches `name` written in UTF-8 or in PDFDocEncoding, +/// or as a text string (UTF-16 or PDFDocEncoding) that reads as `name`, the way a link's string +/// destination is read. +/// Read like [`inspect`]: the lazy reader first, lopdf's tolerant one when the lazy reader fails. +pub fn named_destination(bytes: Arc>, password: Option<&str>, name: &str) -> Option<(usize, DestView)> { + // PDFDocEncoding bytes that are also UTF-8 read as UTF-8, so only this finds "é" written C3 A9. + let pdfdoc: Option> = name.chars().map(pdfcraft_cos::pdfdoc_byte).collect(); + let find = |doc: &dyn Structure| { + let insp = Inspector::new(doc); + let dest = insp + .named_dest(name.as_bytes()) + .or_else(|| pdfdoc.as_deref().and_then(|key| insp.named_dest(key))) + .or_else(|| insp.named_dest_by_text(name))?; + insp.dest(dest, 1).filter(|(page, _)| *page < insp.page_ids.len()) + }; + let lazy = catch_unwind(AssertUnwindSafe(|| { + let doc = LazyStructure::new(bytes.clone(), password).ok()?; + let found = find(&doc); + (!doc.failed()).then_some(found) + })) + .ok() + .flatten(); + if let Some(found) = lazy { + return found; + } + catch_unwind(AssertUnwindSafe(|| find(&Document::load_mem_with_options(&bytes, load_options(password)).ok()?))).ok().flatten() +} + #[cfg(test)] mod open_dest_tests { use std::sync::Arc; @@ -522,6 +564,39 @@ mod open_dest_tests { ); } + fn named(catalog: &str, extra: &[String], name: &str) -> Option<(usize, DestView)> { + super::named_destination(Arc::new(pdf(catalog, extra)), None, name) + } + + #[test] + fn a_named_destination_is_looked_up_by_its_name() { + // A string key of the /Names /Dests name tree, whose value may be a dictionary with /D. + let tree = "/Names << /Dests 6 0 R >>"; + let nodes = ["<< /Kids [7 0 R] >>".to_string(), "<< /Names [(here) [3 0 R /Fit] (there) << /D [5 0 R /XYZ 100 500 2] >>] >>".to_string()]; + assert_eq!(named(tree, &nodes, "there"), Some((2, XYZ))); + assert_eq!(named(tree, &nodes, "here"), Some((0, DestView::Fit))); + // A name key of the PDF 1.1 catalog /Dests dictionary. + assert_eq!(named("/Dests << /old [4 0 R /FitH 400] >>", &[], "old"), Some((1, DestView::FitH { top: Some(400.0) }))); + // A key written as UTF-16 or in PDFDocEncoding matches the text it reads as. + assert_eq!(named("/Names << /Dests << /Names [ [4 0 R /Fit]] >> >>", &[], "thé"), Some((1, DestView::Fit))); + assert_eq!(named("/Names << /Dests << /Names [(th\\351) [5 0 R /Fit]] >> >>", &[], "thé"), Some((2, DestView::Fit))); + // So does a catalog /Dests name written in PDFDocEncoding, as older files have them, but + // a name written as UTF-8 comes first. + assert_eq!(named("/Dests << /th#E9 [4 0 R /Fit] >>", &[], "thé"), Some((1, DestView::Fit))); + assert_eq!(named("/Dests << /th#E9 [4 0 R /Fit] /th#C3#A9 [5 0 R /Fit] >>", &[], "thé"), Some((2, DestView::Fit))); + // A key whose PDFDocEncoding bytes are also UTF-8 reads as UTF-8 ("é"), yet is found by + // the text it holds in PDFDocEncoding ("é") too. + assert_eq!(named("/Dests << /#C3#A9 [4 0 R /Fit] >>", &[], "é"), Some((1, DestView::Fit))); + assert_eq!(named("/Names << /Dests << /Names [(\\303\\251) [5 0 R /Fit]] >> >>", &[], "é"), Some((2, DestView::Fit))); + // A name that isn't there, one past the last page, one that names itself, no names at + // all, and bytes that aren't a PDF. + assert_eq!(named(tree, &nodes, "missing"), None); + assert_eq!(named("/Dests << /far [7 /Fit] >>", &[], "far"), None); + assert_eq!(named("/Dests << /loop /loop >>", &[], "loop"), None); + assert_eq!(named("", &[], "there"), None); + assert_eq!(super::named_destination(Arc::new(b"not a pdf".to_vec()), None, "there"), None); + } + #[test] fn an_open_action_given_as_a_name_or_string_is_looked_up() { assert_eq!(open_dest("/OpenAction /there /Dests << /there [4 0 R /Fit] >>", &[]), Some((1, DestView::Fit))); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0edaf558bc..da5071948e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -21,7 +21,7 @@ pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerItem, LayerOp, LayerRow, Link, LinkTarget, MAX_GROUP_DEPTH, OutlineItem, PageInfo, PageTransition, Popup, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa, - attachment_data, check_pages, fill_group_text, group_primaries, inspect, link_replies, pretty_date, thread_tops, + attachment_data, check_pages, fill_group_text, group_primaries, inspect, link_replies, named_destination, pretty_date, thread_tops, }; pub use link_area::{MAX_LINK_QUADS, MAX_URI_BASE_BYTES, link_quads, quad_contains, resolve_uri}; pub use pixels::Pixels; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 1097da1997..cd88021951 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -269,7 +269,9 @@ pub struct RenderedPage { /// A non-fatal condition reported while interpreting a page. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RenderWarning { - /// Content was skipped after the per-page decoded-content budget was exhausted. + /// Content was left out at one of the renderer's limits: content past the page's + /// decoded-content budget or its JBIG2 allowance, or a picture whose JBIG2 mask is past a + /// decoder limit. ContentTruncated, /// A colour could not be computed (a colour space that cannot be read, or a Separation or /// DeviceN tint transform that cannot be evaluated) and was painted in a fallback colour. diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 32a428c1e5..86d655677b 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -4234,7 +4234,7 @@ fn inline_images_with_ei_in_their_data_parse_in_linear_time() { /// From the nightly `cargo xtask fuzz`: a JBIG2 image whose stream declares a page and a /// generic region of 65535 × 65535 pixels (each side within JBIG2's limit) behind a small /// /Width and /Height: decoding its 4.3 billion pixels one by one took minutes. Vendored -/// hayro-jbig2 patch: at most 2^28 pixels per bitmap, and 2^26 per symbol dictionary. +/// hayro-jbig2 patch: at most 2^28 pixels per bitmap, and as many per symbol dictionary. #[test] fn jbig2_regions_of_billions_of_pixels_are_refused() { // Embedded JBIG2 segments (ISO 14492 §7.2): page information, then an immediate generic @@ -4302,9 +4302,14 @@ fn jbig2_segment(out: &mut Vec, number: u32, kind: u8, refs: &[u8], data: &[ /// The page information of a 16 × 2 JBIG2 page whose default pixel is black, so that the image, if /// it decodes, draws black whatever its other segments hold. fn black_jbig2_page() -> Vec { + black_jbig2_page_of(16, 2) +} + +/// [`black_jbig2_page`], `width` × `height`. +fn black_jbig2_page_of(width: u32, height: u32) -> Vec { let mut page = Vec::new(); - page.extend_from_slice(&16u32.to_be_bytes()); - page.extend_from_slice(&2u32.to_be_bytes()); + page.extend_from_slice(&width.to_be_bytes()); + page.extend_from_slice(&height.to_be_bytes()); page.extend_from_slice(&[0; 8]); // resolution page.extend_from_slice(&[4, 0, 0]); // flags (default pixel black), striping let mut stream = Vec::new(); @@ -4495,6 +4500,289 @@ fn a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn() { assert_eq!(other, (true, false), "and a page with nothing left says nothing of a mask that isn't JBIG2"); } +/// A symbol dictionary may hold as many pixels as the largest page bitmap. It was held to a +/// quarter of that, and the soft mask of a scanned drawing, one dictionary of 7,559 symbols, passed +/// it: refused, the mask left its picture drawn whole, which covered the page in noise (#6722). +#[test] +fn a_symbol_dictionary_holds_as_many_pixels_as_a_page() { + let stream = |heights: &[u32]| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 0, &[], &wide_jbig2_dictionary(65535, heights)); + stream + }; + assert!(jbig2_image_draws(&stream(&[1100])), "72,088,500 pixels, past 2^26"); + assert!(jbig2_image_draws(&stream(&[2047, 2048])), "268,365,825 pixels, within 2^28"); + assert!(!jbig2_image_draws(&stream(&[2048, 2049])), "268,496,895 pixels, past 2^28"); +} + +/// A JBIG2 mask one of the decoder's limits refuses takes its picture with it, as one left out for +/// the page's allowance does: the file isn't to blame, and a scan's colour layer drawn without its +/// mask covers the page in noise (#6722). A mask the file damaged leaves its picture whole, as +/// upstream draws it. A size the file damaged can read as past a limit, and is taken for one. +#[test] +fn a_picture_whose_jbig2_mask_a_decoder_limit_refuses_is_not_drawn() { + let draws = |entry: &str, (width, height): (u32, u32), mask: &[u8]| { + let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice(b"\nendstream endobj\n"); + pdf.extend_from_slice( + format!("5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray {entry} 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n").as_bytes(), + ); + let kind = if entry == "/Mask" { "/ImageMask true" } else { "/ColorSpace /DeviceGray" }; + pdf.extend_from_slice( + format!( + "6 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} /BitsPerComponent 1 {kind} /Filter /JBIG2Decode /Length {} >> stream\n", + mask.len() + ) + .as_bytes(), + ); + pdf.extend_from_slice(mask); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + let shown = match &page.rgba[((25 * 40 + 15) * 4)..][..4] { + [0, 0, 0, 255] => true, + [255, 255, 255, 255] => false, + other => panic!("neither the picture nor the page: {other:?}"), + }; + (shown, page.warnings.contains(&RenderWarning::ContentTruncated)) + }; + let out = (false, true); + // A page a column wider than the decoder's bound on a side, as the mask's dictionary says too. + let wide = black_jbig2_page_of(65536, 2); + assert_eq!(draws("/SMask", (65536, 2), &wide), out, "a soft mask past the decoder's bound on a side"); + assert_eq!(draws("/Mask", (65536, 2), &wide), out, "a stencil mask past it"); + // A page of 65,535 rows and columns, past the pixels the renderer decodes, as the dictionary + // says too. + let large = black_jbig2_page_of(65535, 65535); + assert_eq!(draws("/SMask", (65535, 65535), &large), out, "a soft mask past the renderer's bound on pixels"); + assert_eq!(draws("/Mask", (65535, 65535), &large), out, "a stencil mask past it"); + // A dictionary of more symbol pixels than a page holds. + let mut dictionary = black_jbig2_page(); + jbig2_segment(&mut dictionary, 1, 0, &[], &wide_jbig2_dictionary(65535, &[2048, 2049])); + assert_eq!(draws("/SMask", (16, 2), &dictionary), out, "a soft mask whose dictionary is past its bound"); + // Cut short in its dictionary: the file's damage, not a limit. + let mut damaged = black_jbig2_page(); + jbig2_segment(&mut damaged, 1, 0, &[], &wide_jbig2_dictionary(100, &[100])[..12]); + assert_eq!(draws("/SMask", (16, 2), &damaged), (true, false), "a damaged mask is no mask"); + // Control: the stream's page is far larger than the dictionary says. That is damage, which + // reads as past the decoder's bound on pixels, and is taken for it: it can't be told from a + // hostile size, and leaving the picture out is the safe side. + let disagreeing = black_jbig2_page_of(65535, 65535); + assert_eq!(draws("/SMask", (16, 2), &disagreeing), out, "a size damaged past a limit is taken for one"); + + // A stencil mask a limit refused is drawn on its own: it takes nothing else out, and is a + // failed image, not content left out (`a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image`). + // Nor does it leave anything behind for the next picture: one whose mask is never decoded (it + // has no /Width) is drawn whole, as upstream draws it. + let content = b"q 4 0 0 4 30 30 cm /St Do Q q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R /St 7 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice(b"\nendstream endobj\n5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n"); + let mask = black_jbig2_page(); + pdf.extend_from_slice(format!("6 0 obj << /Type /XObject /Subtype /Image /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", mask.len()).as_bytes()); + pdf.extend_from_slice(&mask); + pdf.extend_from_slice(format!("\nendstream endobj\n7 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /ImageMask true /Filter /JBIG2Decode /Length {} >> stream\n", wide.len()).as_bytes()); + pdf.extend_from_slice(&wide); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(&page.rgba[((25 * 40 + 15) * 4)..][..4], [0, 0, 0, 255], "the picture after the stencil is drawn whole"); + assert!(page.warnings.is_empty(), "the stencil left nothing out: {:?}", page.warnings); +} + +/// A JBIG2 image drawn on its own that a limit refuses, a stencil mask included, takes nothing else +/// out, so it is a failed image, as it was before #6722: only a picture's mask refused by a limit +/// is reported as content left out, with the picture it takes along (patch 73). Refused by the +/// decoder's bound on a side or by the renderer's bound on pixels, each image's `/Width` and +/// `/Height` those of its JBIG2 page. +#[test] +fn a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image() { + use hayro::hayro_interpret::font::Glyph; + use hayro::hayro_interpret::hayro_syntax::Pdf; + use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + TransformExt, interpret_page, + }; + use kurbo::{Affine, BezPath, Rect}; + + /// Asks each image for its pixels, which decodes it, and draws nothing. + struct Pixels; + impl<'a> Device<'a> for Pixels { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, _: Affine) { + match image { + Image::Raster(r) => r.with_rgba(|_, _| {}, Some((20, 20))), + Image::Stencil(s) => s.with_stencil(|_, _| {}, Some((20, 20))), + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + // The warnings drawing one image reports. + let warnings = |kind: &str, (width, height): (u32, u32)| { + let stream = black_jbig2_page_of(width, height); + let content = "q 20 0 0 20 5 5 cm /Im Do Q"; + let mut 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 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n5 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} {kind} /Filter /JBIG2Decode /Length {} >> stream\n", + content.len(), + stream.len() + ) + .into_bytes(); + pdf.extend_from_slice(&stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let pdf = Pdf::new(Arc::new(pdf)).expect("parses"); + let page = &pdf.pages()[0]; + let cache = InterpreterCache::new(); + let reported = Arc::new(Mutex::new(Vec::new())); + let settings = InterpreterSettings { + warning_sink: { + let reported = reported.clone(); + Arc::new(move |warning| reported.lock().unwrap().push(format!("{warning:?}"))) + }, + ..InterpreterSettings::default() + }; + let mut ctx = Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 40.0, 40.0), &cache, page.xref(), settings); + interpret_page(page, &mut ctx, &mut Pixels); + reported.lock().unwrap().clone() + }; + let stencil = "/ImageMask true"; + let raster = "/BitsPerComponent 1 /ColorSpace /DeviceGray"; + let failed = ["ImageDecodeFailure"]; + assert_eq!(warnings(stencil, (65536, 2)), failed, "a stencil mask past the decoder's bound on a side"); + assert_eq!(warnings(stencil, (65535, 65535)), failed, "a stencil mask past the renderer's bound on pixels"); + assert_eq!(warnings(raster, (65536, 2)), failed, "an image past the decoder's bound on a side"); + assert_eq!(warnings(raster, (65535, 65535)), failed, "an image past the renderer's bound on pixels"); +} + +/// What refused one image says nothing of the next: after a picture one of the decoder's limits +/// refused, a stencil mask and a soft mask whose filters fail before JBIG2 is reached (LZW data that +/// doesn't decode) are bad, not refused by a limit, on that page and on the next. The decoder's +/// note of the refusal was read before a decode had set it, and outlived its page: the stencil +/// said content was left out, and the picture under the soft mask wasn't drawn. +#[test] +fn a_limit_refusal_says_nothing_of_the_next_image() { + let refused = black_jbig2_page_of(65536, 2); + let bad = [0xff; 64]; + let page = |number: u8, content: u8| { + format!("{number} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents {content} 0 R /Resources << /XObject << /Big 7 0 R /St 8 0 R /Im 9 0 R >> >> >> endobj\n").into_bytes() + }; + let content = |number: u8, text: &str| format!("{number} 0 obj << /Length {} >> stream\n{text}\nendstream endobj\n", text.len()).into_bytes(); + let object = |number: u8, dict: &str, data: &[u8]| { + let mut object = format!("{number} 0 obj << {dict} /Length {} >> stream\n", data.len()).into_bytes(); + object.extend_from_slice(data); + object.extend_from_slice(b"\nendstream endobj\n"); + object + }; + let mut pdf = + b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n".to_vec(); + pdf.extend(page(3, 5)); + pdf.extend(page(4, 6)); + pdf.extend(content(5, "q 10 0 0 10 30 30 cm /Big Do Q q 10 0 0 10 30 0 cm 0 g /St Do Q q 20 0 0 20 5 5 cm /Im Do Q")); + pdf.extend(content(6, "q 10 0 0 10 30 0 cm 0 g /St Do Q q 20 0 0 20 5 5 cm /Im Do Q")); + pdf.extend(object( + 7, + "/Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode", + &refused, + )); + pdf.extend(object(8, "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ImageMask true /Filter [/LZWDecode /JBIG2Decode]", &bad)); + pdf.extend(object(9, "/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 10 0 R", &[0])); + pdf.extend(object( + 10, + "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter [/LZWDecode /JBIG2Decode]", + &bad, + )); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + // Both pages from one renderer, on one thread. + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let [first, second] = + [0, 1].map(|page| renderer.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); + let drawn = |page: &RenderedPage| { + assert!(page.error.is_none(), "{:?}", page.error); + (page.rgba[((25 * 40 + 15) * 4)..][..4] == [0, 0, 0, 255], page.warnings.contains(&RenderWarning::ContentTruncated)) + }; + // The next page first: on the page of the refusal, the stale note shows whether or not it + // outlives the page. + assert_eq!(drawn(&second), (true, false), "on the next page, the picture is drawn and nothing was left out"); + assert_eq!(drawn(&first), (true, false), "on its page, the picture is drawn and nothing was left out"); +} + +/// A JPX picture is decoded again for each size a page draws it at, while a soft mask far larger +/// than where it is drawn is averaged down once and kept for the sizes it serves (patch 82). A +/// JBIG2 mask a decoder limit refused is kept refused, so the picture drawn at another size is left +/// out again, not drawn whole because the mask wasn't decoded that time. The scans of #6722 are +/// JPX under such a mask. +#[test] +fn a_jpx_picture_whose_jbig2_mask_a_limit_refuses_stays_out_at_every_size() { + // A page a column wider than the decoder's bound on a side, as the mask's dictionary says too. + let wide = black_jbig2_page_of(65536, 2); + // A 64 × 64 grey JPX codestream, every sample 128 (as `jpx_midpoint_pdf`'s, larger): larger + // than where it is drawn, it is decoded for each size it is drawn at. + let mut codestream = vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0]; // SOC, SIZ, Rsiz. + for value in [64_u32, 64, 0, 0, 64, 64, 0, 0] { + codestream.extend_from_slice(&value.to_be_bytes()); + } + codestream.extend_from_slice(&[0, 1, 7, 1, 1]); // One component, eight bits. + // COD: one decomposition level, so it decodes at half its size for a small target; QCD: no + // quantization, the four subbands' exponents. + codestream.extend_from_slice(&[0xff, 0x52, 0, 12, 0, 0, 0, 1, 0, 1, 4, 4, 0, 1]); + codestream.extend_from_slice(&[0xff, 0x5c, 0, 7, 0x40, 0x40, 0x48, 0x48, 0x50]); + codestream.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0, 0, 0, 0, 16, 0, 1, 0xff, 0x93, 0, 0]); // SOT, SOD, two empty packets. + codestream.extend_from_slice(&[0xff, 0xd9]); // EOC. + // At 10, 20 and 10 units square: the second draw is a new size for the picture. + let content = b"q 10 0 0 10 2 2 cm /Im1 Do Q q 20 0 0 20 16 16 cm /Im1 Do Q q 10 0 0 10 2 28 cm /Im1 Do Q"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice( + format!("\nendstream endobj\n5 0 obj << /Type /XObject /Subtype /Image /Width 64 /Height 64 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R /Filter /JPXDecode /Length {} >> stream\n", codestream.len()) + .as_bytes(), + ); + pdf.extend_from_slice(&codestream); + pdf.extend_from_slice( + format!("\nendstream endobj\n6 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", wide.len()) + .as_bytes(), + ); + pdf.extend_from_slice(&wide); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + // The middle of each draw, in rows from the top. + let shown = [(33, 7), (14, 26), (7, 7)].map(|(row, column)| page.rgba[(row * 40 + column) * 4..][..4] != [255, 255, 255, 255]); + assert_eq!(shown, [false; 3], "drawn at 10, 20 and 10"); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated)); +} + /// A page's allowance ends with it, so what decodes on its thread afterwards has no page to run out /// on, and a `Context` used again starts afresh. #[test] @@ -4747,6 +5035,40 @@ fn narrow_jbig2_dictionary(symbols: u32, height: u32) -> Vec { dictionary } +/// A Huffman-coded symbol dictionary of one white symbol of `width` columns in each height class +/// of `heights` (rising), none exported: wide symbols hold many pixels in few retained words. +fn wide_jbig2_dictionary(width: u32, heights: &[u32]) -> Vec { + let mut bits = Bits::default(); + let mut previous = 0; + for &height in heights { + match height - previous { + // Height class delta (table B.4): 1, or the upper line. + 1 => bits.put(0, 1), + delta => { + bits.put(0b11111, 5); + bits.put(delta - 76, 32); + } + } + previous = height; + bits.put(0b111110, 6); // width delta, the upper line of table B.2 + bits.put(width - 75, 32); + bits.put(0b111111, 6); // end of the class (B.2) + let rows = height.div_ceil(8) + 1; // an MMR-coded white bitmap: one bit a row + bits.table_b1(rows); // BMSIZE (B.1) + bits.align(); + for _ in 0..rows { + bits.put(0xFF, 8); + } + } + let symbols = u32::try_from(heights.len()).unwrap(); + bits.table_b1(symbols); // the first export run, not exported, is all of them + let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1 + dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported + dictionary.extend_from_slice(&symbols.to_be_bytes()); // new + dictionary.extend_from_slice(&bits.bytes); + dictionary +} + /// An intermediate generic region, white, every row typical: it decodes in a few bytes however /// large it is. fn jbig2_white_region(width: u32, height: u32) -> Vec { diff --git a/crates/render/src/raster/tests/failed_image_decodes.rs b/crates/render/src/raster/tests/failed_image_decodes.rs index bd0329ed31..5e8995f23a 100644 --- a/crates/render/src/raster/tests/failed_image_decodes.rs +++ b/crates/render/src/raster/tests/failed_image_decodes.rs @@ -237,3 +237,114 @@ fn a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page() { assert!(black(&second, 10) && black(&second, 30), "A, and B left out of the first page, draw on the second"); assert!(second.1.is_empty(), "{:?}", second.1); } + +/// A JBIG2 mask its decoding work refuses: four typical-row regions of 16,384 × 16,384 take more +/// than a stream may. +fn jbig2_mask_past_its_work() -> Vec { + use super::{black_jbig2_page, jbig2_segment, jbig2_white_region}; + let mut mask = black_jbig2_page(); + for number in 1..=4 { + jbig2_segment(&mut mask, number, 38, &[], &jbig2_white_region(16384, 16384)); // immediate generic region + } + mask +} + +/// What drawing 64 × 64 pictures under JBIG2 soft masks did, with the page's JBIG2 allowance +/// `work`: `/P` under `mask` (`/Width` × `/Height` `size`) and `/W` under +/// [`jbig2_mask_past_its_work`], each of `draws` a name and a size (units square). The decodes the +/// cache ran, how many times content was said to be left out, and what the device was handed. +fn draw_under_jbig2_masks(mask: &[u8], size: (u32, u32), draws: &[(&str, u32)], work: u64) -> (usize, usize, Draws) { + let pixels = vec![0; 64 * 64 * 3]; + let picture = |mask: u8| { + format!( + "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask {mask} 0 R /Length {} >>", + pixels.len() + ) + }; + let soft_mask = |(width, height): (u32, u32), data: &[u8]| { + format!( + "<< /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /JBIG2Decode /Length {} >>", + data.len() + ) + }; + let past_its_work = jbig2_mask_past_its_work(); + let content = draws.iter().map(|(name, s)| format!("q {s} 0 0 {s} 10 10 cm /{name} Do Q")).collect::>().join(" "); + let bytes = image_cache_pdf( + &content, + "<< /XObject << /P 5 0 R /W 7 0 R >> >>", + &[(&picture(6), &pixels), (&soft_mask(size, mask), mask), (&picture(8), &pixels), (&soft_mask((16, 2), &past_its_work), &past_its_work)], + ); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let truncated = Arc::new(AtomicUsize::new(0)); + let others = Arc::new(Mutex::new(Vec::new())); + let settings = InterpreterSettings { + warning_sink: { + let (truncated, others) = (truncated.clone(), others.clone()); + Arc::new(move |warning| match warning { + InterpreterWarning::ContentTruncated => { + truncated.fetch_add(1, Ordering::Relaxed); + } + other => others.lock().unwrap().push(format!("{other:?}")), + }) + }, + jbig2_work: work, + ..InterpreterSettings::default() + }; + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), settings); + let mut device = Draws::default(); + interpret_page(page, &mut ctx, &mut device); + assert!(others.lock().unwrap().is_empty(), "reported {:?}", others.lock().unwrap()); + (cache.decoded_image_decodes(), truncated.load(Ordering::Relaxed), device) +} + +/// A picture whose JBIG2 soft mask a decoder limit refuses isn't drawn (patch 73), and the limit +/// is the stream's own: decoding it again refuses it again. So the refusal is kept with the picture +/// that went with it (patch 91), and a page drawing it a hundred times decodes them once, as for +/// one draw. Kept as content left out, every draw still says so. The refusal wasn't kept, and the +/// picture, of any size, was decoded again at every draw: 2.45 s for a hundred draws of a 4,096 × +/// 4,096 JPX picture, against 0.23 s under a mask the file damaged. A mask larger than its picture +/// is averaged down for each size it is drawn at, from one kept at an intermediate size (patch 82), +/// so a second size finds the refusal kept there and keeps it for that size too, also after +/// another picture's mask was refused for its work, which isn't kept. +#[test] +fn a_picture_whose_jbig2_mask_a_limit_refuses_is_decoded_once_however_often_it_is_drawn() { + use super::{black_jbig2_page, black_jbig2_page_of, jbig2_segment, wide_jbig2_dictionary}; + let mut dictionary = black_jbig2_page(); + jbig2_segment(&mut dictionary, 1, 0, &[], &wide_jbig2_dictionary(65535, &[2048, 2049])); + let masks = [ + ("past the decoder's bound on a side", black_jbig2_page_of(65536, 2), (65536, 2)), + ("past the renderer's bound on pixels", black_jbig2_page_of(65535, 65535), (65535, 65535)), + ("whose dictionary is past its bound", dictionary, (16, 2)), + ]; + let work = InterpreterSettings::default().jbig2_work; + for (what, mask, size) in masks { + let (once, _, _) = draw_under_jbig2_masks(&mask, size, &[("P", 20)], work); + let (decodes, truncated, draws) = draw_under_jbig2_masks(&mask, size, &[("P", 20); 100], work); + assert_eq!(draws.rasters, 0, "a mask {what} leaves the picture out"); + assert_eq!(decodes, once, "a mask {what}: a hundred draws decode as one does"); + assert_eq!(truncated, 100, "a mask {what}: every draw says content was left out"); + // With no page allowance to run out of, so that the mask past its work is refused by its + // own limit and the page's allowance doesn't stop what is kept. + let each = [("P", 20), ("W", 20), ("P", 40)]; + let (once_each, _, _) = draw_under_jbig2_masks(&mask, size, &each, u64::MAX); + let (decodes, _, draws) = draw_under_jbig2_masks(&mask, size, &[&each[..], &[("P", 40); 2]].concat(), u64::MAX); + assert_eq!(draws.rasters, 0, "a mask {what} leaves the picture out at either size"); + assert_eq!(decodes, once_each, "a mask {what}: drawn again at its second size, decoded as once at each"); + } +} + +/// A JBIG2 mask refused for the work its decoding takes isn't kept: that work may be what its page +/// had left, which another page has afresh. So the picture under it is decoded at every draw, here +/// with no page allowance to run out of, where the stream's own limit is what refuses it. +#[test] +fn a_picture_whose_jbig2_mask_is_refused_for_its_work_is_decoded_at_every_draw() { + let mask = super::black_jbig2_page(); + let (once, _, _) = draw_under_jbig2_masks(&mask, (16, 2), &[("W", 20)], u64::MAX); + let (decodes, truncated, draws) = draw_under_jbig2_masks(&mask, (16, 2), &[("W", 20); 3], u64::MAX); + assert_eq!(draws.rasters, 0, "the picture is left out"); + assert_eq!(decodes, 3 * once, "decoded at every draw"); + assert_eq!(truncated, 3, "every draw says content was left out"); +} diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index bfb04a26cb..a72d91ad2a 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -17,6 +17,7 @@ pdfcraft-content = { workspace = true } pdfcraft-fonts = { workspace = true } pdfcraft-model = { workspace = true } thiserror.workspace = true +zeroize.workspace = true # ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code). chrono = { workspace = true } base64 = "0.23" diff --git a/crates/sign/src/keys.rs b/crates/sign/src/keys.rs index 0521c5d914..b5d466ae81 100644 --- a/crates/sign/src/keys.rs +++ b/crates/sign/src/keys.rs @@ -4,6 +4,8 @@ //! (constant-time; ADR-0009: the `rsa` crate is never used for private keys there). ECDSA //! signing uses RustCrypto with deterministic nonces (RFC 6979). +use zeroize::Zeroizing; + use crate::SignError; use crate::der::{self, Tlv, tag}; @@ -588,7 +590,7 @@ pub struct PrivateKey { inner: Inner, public: PublicKey, /// The PKCS#8 `PrivateKeyInfo` it came from (to write it back into a PKCS#12 file). - pkcs8: Vec, + pub(crate) pkcs8: Zeroizing>, } impl std::fmt::Debug for PrivateKey { @@ -601,7 +603,7 @@ impl PrivateKey { /// A key held outside PdfCraft's memory (its public half is `public`). It can't be /// exported to a PKCS #12 file. pub fn external(public: PublicKey, key: std::sync::Arc) -> PrivateKey { - PrivateKey { inner: Inner::External(key), public, pkcs8: Vec::new() } + PrivateKey { inner: Inner::External(key), public, pkcs8: Zeroizing::new(Vec::new()) } } /// Whether the key lives outside the app (and so can't be exported). @@ -631,7 +633,7 @@ impl PrivateKey { ); #[cfg(target_arch = "wasm32")] let inner = Inner::Rsa; - Ok(PrivateKey { inner, public: PublicKey::Rsa { n, e }, pkcs8: info.to_vec() }) + Ok(PrivateKey { inner, public: PublicKey::Rsa { n, e }, pkcs8: Zeroizing::new(info.to_vec()) }) } oid::EC => { // ECPrivateKey: version, privateKey OCTET STRING, [0] parameters, [1] publicKey. @@ -650,12 +652,12 @@ impl PrivateKey { oid::P256 => { let k = p256::ecdsa::SigningKey::from_slice(d).map_err(|_| SignError::Malformed("P-256 key".into()))?; let public = PublicKey::P256(k.verifying_key().to_sec1_point(false).as_bytes().to_vec()); - Ok(PrivateKey { inner: Inner::P256(k), public, pkcs8: info.to_vec() }) + Ok(PrivateKey { inner: Inner::P256(k), public, pkcs8: Zeroizing::new(info.to_vec()) }) } oid::P384 => { let k = p384::ecdsa::SigningKey::from_slice(d).map_err(|_| SignError::Malformed("P-384 key".into()))?; let public = PublicKey::P384(k.verifying_key().to_sec1_point(false).as_bytes().to_vec()); - Ok(PrivateKey { inner: Inner::P384(k), public, pkcs8: info.to_vec() }) + Ok(PrivateKey { inner: Inner::P384(k), public, pkcs8: Zeroizing::new(info.to_vec()) }) } other => Err(SignError::Unsupported(format!("elliptic curve {other}"))), } @@ -689,13 +691,15 @@ impl PrivateKey { /// A new P-256 key. pub fn generate_p256() -> Result { loop { - let mut d = [0u8; 32]; - getrandom::fill(&mut d).map_err(|e| SignError::Crypto(format!("no randomness: {e}")))?; - if let Ok(k) = p256::ecdsa::SigningKey::from_slice(&d) { + let mut d = Zeroizing::new([0u8; 32]); + getrandom::fill(&mut *d).map_err(|e| SignError::Crypto(format!("no randomness: {e}")))?; + if let Ok(k) = p256::ecdsa::SigningKey::from_slice(&*d) { let point = k.verifying_key().to_sec1_point(false).as_bytes().to_vec(); // PKCS#8 with an ECPrivateKey (RFC 5915) carrying the public key. - let ec = der::seq(&[&der::int(1), &der::octets(&d), &der::explicit(1, &der::bit_string(&point))]); - let info = der::seq(&[&der::int(0), &der::seq(&[&der::oid(oid::EC), &der::oid(oid::P256)]), &der::octets(&ec)]); + let d_octets = Zeroizing::new(der::octets(&*d)); + let ec = Zeroizing::new(der::seq(&[&der::int(1), &d_octets, &der::explicit(1, &der::bit_string(&point))])); + let ec_octets = Zeroizing::new(der::octets(&ec)); + let info = Zeroizing::new(der::seq(&[&der::int(0), &der::seq(&[&der::oid(oid::EC), &der::oid(oid::P256)]), &ec_octets])); return PrivateKey::from_pkcs8(&info); } } diff --git a/crates/sign/src/pkcs12.rs b/crates/sign/src/pkcs12.rs index a71d5c8584..77a1019ff6 100644 --- a/crates/sign/src/pkcs12.rs +++ b/crates/sign/src/pkcs12.rs @@ -20,6 +20,7 @@ use base64::Engine as _; use cbc::cipher::block_padding::Pkcs7; use cbc::cipher::{BlockModeDecrypt, BlockModeEncrypt, InnerIvInit, KeyIvInit}; use hmac::Mac; +use zeroize::Zeroizing; use crate::SignError; use crate::der::{self, Tlv, tag}; @@ -100,14 +101,14 @@ fn bad(what: &str) -> SignError { } /// The password as a BMPString with its terminating NUL (RFC 7292 Appendix B.1). -fn bmp(password: &str) -> Vec { - let mut v: Vec = password.encode_utf16().flat_map(u16::to_be_bytes).collect(); +fn bmp(password: &str) -> Zeroizing> { + let mut v = Zeroizing::new(password.encode_utf16().flat_map(u16::to_be_bytes).collect::>()); v.extend([0, 0]); v } /// The PKCS #12 key derivation function (RFC 7292 Appendix B.2). -fn pkcs12_kdf(alg: DigestAlg, password: &[u8], salt: &[u8], id: u8, iterations: u32, n: usize) -> Vec { +fn pkcs12_kdf(alg: DigestAlg, password: &[u8], salt: &[u8], id: u8, iterations: u32, n: usize) -> Zeroizing> { // Callers only pass the SHA-1/SHA-2 digests PKCS #12 files use (`open` restricts the MAC digest). let (u, v) = match alg { DigestAlg::Sha1 => (20, 64), @@ -115,25 +116,25 @@ fn pkcs12_kdf(alg: DigestAlg, password: &[u8], salt: &[u8], id: u8, iterations: DigestAlg::Sha256 => (32, 64), DigestAlg::Sha384 => (48, 128), DigestAlg::Sha512 => (64, 128), - _ => return Vec::new(), + _ => return Zeroizing::new(Vec::new()), }; let _ = u; - let fill = |s: &[u8]| -> Vec { + let fill = |s: &[u8]| -> Zeroizing> { if s.is_empty() { - return Vec::new(); + return Zeroizing::new(Vec::new()); } let len = v * s.len().div_ceil(v); - s.iter().cycle().take(len).copied().collect() + Zeroizing::new(s.iter().cycle().take(len).copied().collect()) }; let d = vec![id; v]; - let mut i: Vec = [fill(salt), fill(password)].concat(); - let mut out = Vec::with_capacity(n); + let mut i = Zeroizing::new([fill(salt).as_slice(), fill(password).as_slice()].concat()); + let mut out = Zeroizing::new(Vec::with_capacity(n)); while out.len() < n { - let mut a = alg.digest(&[&d, &i]); + let mut a = Zeroizing::new(alg.digest(&[&d, &i])); for _ in 1..iterations.max(1) { - a = alg.digest(&[&a]); + a = Zeroizing::new(alg.digest(&[&a])); } - let b: Vec = a.iter().cycle().take(v).copied().collect(); + let b = Zeroizing::new(a.iter().cycle().take(v).copied().collect::>()); for chunk in i.chunks_mut(v) { // chunk = (chunk + b + 1) mod 2^(8v) let mut carry = 1u16; @@ -169,7 +170,7 @@ impl Cipher { } } - fn decrypt(self, key: &[u8], iv: &[u8], ct: &[u8]) -> Result, SignError> { + fn decrypt(self, key: &[u8], iv: &[u8], ct: &[u8]) -> Result>, SignError> { let wrong = || SignError::WrongPassword; let r = match self { Cipher::Aes128 => cbc::Decryptor::::new_from_slices(key, iv).map_err(|_| wrong())?.decrypt_padded_vec::(ct), @@ -181,7 +182,7 @@ impl Cipher { cbc::Decryptor::::inner_iv_slice_init(c, iv).map_err(|_| wrong())?.decrypt_padded_vec::(ct) } }; - r.map_err(|_| wrong()) + r.map(Zeroizing::new).map_err(|_| wrong()) } } @@ -205,8 +206,8 @@ fn hmac(alg: DigestAlg, key: &[u8], data: &[u8]) -> Result, SignError> { } } -fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize) -> Result, SignError> { - let mut out = vec![0u8; len]; +fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize) -> Result>, SignError> { + let mut out = Zeroizing::new(vec![0u8; len]); match prf { DigestAlg::Sha1 => pbkdf2::pbkdf2_hmac::(password, salt, rounds, &mut out), DigestAlg::Sha224 => pbkdf2::pbkdf2_hmac::(password, salt, rounds, &mut out), @@ -219,7 +220,7 @@ fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize) } /// Decrypt `ct` with the password-based scheme `alg` (an AlgorithmIdentifier). -fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) -> Result, SignError> { +fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) -> Result>, SignError> { let parts = alg.children()?; let o = parts.first().ok_or_else(|| bad("algorithm"))?.oid()?; let params = parts.get(1).ok_or_else(|| bad("algorithm parameters"))?; @@ -280,8 +281,7 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) -> let mut key = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 1, rounds, key_len); if key.len() == 16 && matches!(cipher, Cipher::TripleDes) { // Two-key 3DES: K1 K2 K1. - let k1 = key[..8].to_vec(); - key.extend(k1); + key.extend_from_within(..8); } let iv = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 2, rounds, iv_len); cipher.decrypt(&key, &iv, ct) @@ -297,7 +297,7 @@ struct Bag { } enum BagKind { - Key(Vec), + Key(Zeroizing>), Cert(Vec), } @@ -319,7 +319,7 @@ fn bags(safe_contents: &[u8], password: &str, out: &mut Vec, budget: &mut B } } let kind = match id.as_str() { - KEY_BAG => BagKind::Key(value.raw.to_vec()), + KEY_BAG => BagKind::Key(Zeroizing::new(value.raw.to_vec())), SHROUDED_KEY_BAG => { let p = value.children()?; let [alg, data] = p.as_slice() else { return Err(bad("EncryptedPrivateKeyInfo")) }; @@ -549,4 +549,16 @@ mod tests { assert_eq!(budget.spend(DigestAlg::Sha1, 0, 20).ok(), Some(0)); assert_eq!(budget.0, OPEN_BUDGET - 1); } + + #[test] + fn key_and_kdf_buffers_are_zeroizing() { + let key = PrivateKey::generate_p256().unwrap(); + let _: &Zeroizing> = &key.pkcs8; + let pw: Zeroizing> = bmp("secret12"); + assert_eq!(&*pw, &[0, b's', 0, b'e', 0, b'c', 0, b'r', 0, b'e', 0, b't', 0, b'1', 0, b'2', 0, 0]); + let kdf: Zeroizing> = pkcs12_kdf(DigestAlg::Sha256, &pw, &[1, 2, 3, 4], 1, 1, 32); + assert_eq!(kdf.len(), 32); + let derived: Zeroizing> = pbkdf2(DigestAlg::Sha256, b"secret12", &[1, 2, 3, 4], 1, 32).unwrap(); + assert_eq!(derived.len(), 32); + } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 7f9f39894e..b4e7022220 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -34,6 +34,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "acces serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +zeroize = { workspace = true } # UTC/calendar display (combine modified-time stamp, the form date picker) goes through chrono # (no hand-rolled calendar code); std-only, so it stays wasm-safe. chrono = { workspace = true } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index a0f9a2b7cd..240ea16a8e 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -133,6 +133,7 @@ impl PdfCraftApp { printer_source: None, options_source: None, spooler: None, + two_sided_source: None, print_tasks: print_ui::PrintTasks::default(), palette_open: false, palette_query: String::new(), @@ -163,6 +164,8 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] password_retry: None, #[cfg(not(target_arch = "wasm32"))] + pending_destinations: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] next_open_token: 0, #[cfg(not(target_arch = "wasm32"))] launch_layout: None, @@ -299,6 +302,8 @@ impl PdfCraftApp { deferred_commands: Vec::new(), native_menu: None, menu_input: Vec::new(), + menu_pastes: 0, + page_keys: Default::default(), dialog_seen: None, dialog_epoch: 0, ctx: None, @@ -377,6 +382,61 @@ impl PdfCraftApp { self.active.and_then(|i| self.views.get(i).map(|v| (i, v.id))) } + /// Whether something over the pages has the keyboard: what covers them + /// ([`Self::covers_pages`]), the hotbar's tools window, a popup, a menu of the menu bar, or the + /// bar holding the keyboard (Alt, F10). + pub(crate) fn over_pages(&self, ctx: &egui::Context) -> bool { + self.covers_pages() + || self.hotbar.picker_over_pages(ctx) + || egui::Popup::is_any_open(ctx) + || craft_menu::bar::is_open(ctx) + || craft_menu::bar::is_focused(ctx) + } + + /// Edit ▸ Copy, Cut, Select all and Delete stand for keys, and Edit ▸ Paste for the paste the + /// platform answers with. They go where the user's would: to a text field that has the + /// keyboard, else to the pages. They join the input once the menu bar has let go of the keys, + /// which an in-window menu's click does a pass later (craft-menu stores the menu as open on + /// the pass its click closes it). A native menu's click leaves egui's popups and the Hotbar + /// tools window open, which hold the pages' keys; a click anywhere else closes them, and so + /// does this. Edit ▸ Paste asks the platform for the clipboard only then, so its answer, on a + /// later pass, is an ordinary paste. With a text field focused, a native click on Copy, Cut, + /// Paste or Select all doesn't come here: craft-menu taps the shortcut for the field instead. + fn release_menu_input(&mut self, ctx: &egui::Context) { + if self.menu_input.is_empty() && self.menu_pastes == 0 { + return; + } + if craft_menu::bar::is_open(ctx) || craft_menu::bar::is_focused(ctx) { + ctx.request_repaint(); + return; + } + if !ctx.text_edit_focused() { + egui::Popup::close_all(ctx); + self.hotbar.close_picker(); + } + let events = std::mem::take(&mut self.menu_input); + ctx.input_mut(|i| i.events.extend(events)); + for _ in 0..std::mem::take(&mut self.menu_pastes) { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste); + } + } + + /// Read mode and full screen hide the hotbar (the quick tools), and its tools window with it. + pub(crate) fn hotbar_hidden(&self) -> bool { + self.mode == crate::Mode::Read || self.full_screen + } + + /// The pages are covered, and no page key reaches them, a menu click's included: by a dialog or + /// prompt that turns the menus off (`modal_open`), by the full-screen and update prompts, which + /// leave the menus on, by the palette, which turns them off, or by the OCR Verify screen, which + /// takes the pages' place. + pub(crate) fn covers_pages(&self) -> bool { + // Full screen draws the page alone: not the palette, nor the OCR Verify screen it was + // entered over. Dialogs and prompts show there too. + let shown = !(self.full_screen && self.active.is_some()); + self.modal_open() || self.pending_full_screen.is_some() || self.updates.prompt_open() || (self.palette_open || self.ocr_verify.open) && shown + } + /// A dialog or prompt is open over the window: the keyboard is its own, and nothing may act /// on the document underneath it. pub(crate) fn modal_open(&self) -> bool { @@ -537,6 +597,7 @@ impl PdfCraftApp { worker.set_context(ctx); } self.poll_pending_opens(); + self.poll_pending_destinations(); } self.check_restored_recent(); // Chinese wants its own faces before the Japanese ones (one baseline per line). @@ -674,11 +735,13 @@ impl PdfCraftApp { // Shortcuts deferred last frame: the text field has taken that frame's typing since. let deferred = std::mem::take(&mut self.deferred_commands); self.native_menu_clicks(ctx); - // The keys a menu click stands for (⌘C, Delete…), before anything reads this frame's keys. - if !self.menu_input.is_empty() { - let events = std::mem::take(&mut self.menu_input); - ctx.input_mut(|i| i.events.extend(events)); - } + // The input a menu click stands for (⌘C, Delete…), before anything reads this frame's keys. + self.release_menu_input(ctx); + // A command's chord pressed with the keyboard on the menu bar (Alt, F10) takes it off the + // bar, so the chord runs in the same press, the pages' as well as the registry's. + craft_menu::bar::leave_for_shortcut(ctx); + self.hotbar.close_hidden_picker(ctx, self.hotbar_hidden()); + self.page_keys = page_keys::PageKeys::new(self.over_pages(ctx)); self.shortcuts(ctx); // Scrolling and group-drag previews are transient: never resume after changing tabs, // opening a modal/palette, or returning to a window that lost focus. diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 3de7164f49..ce4b60bcd3 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -46,7 +46,8 @@ impl PdfCraftApp { } /// ⌘C / ⌘X / ⌘V copy, cut and paste text, images or comments on canvas (unless a text field - /// has focus). With no text selected, ⌘C copies the selected comments, each with its replies. + /// has focus, or something is over the pages). With no text selected, ⌘C copies the selected + /// comments, each with its replies. pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) { use egui::{Key, KeyboardShortcut, Modifiers}; let pastes: Vec<_> = ctx.input(|input| { @@ -60,7 +61,7 @@ impl PdfCraftApp { }) .collect() }); - let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input() && !self.is_preparing(); + let free = self.dialog.is_none() && !self.palette_open && !self.page_keys.over() && !ctx.egui_wants_keyboard_input() && !self.is_preparing(); if !free || self.views.get(i).is_some_and(|v| v.forms.focus.is_some() || v.organize) { self.receive_pastes(i, pastes, false); return; diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 986ec745d4..058280e32b 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -61,6 +61,21 @@ impl PdfCraftApp { pub(crate) fn select_tool(&mut self, id: &'static str) { // The tool's panel shows, not the expanded All tools view over it. self.all_tools_full = false; + // Edit a PDF turns on Edit text & images at once, as the Edit tab does (`enter_mode`), so + // the page is editable without a second click. Leaving it for another tool from All tools + // puts editing down again; under the Edit tab the workspace keeps it, as before. Read mode + // hides the quick toolbar and keeps every tool down (`enter_mode`), so the control + // channel's `tool edit` there leaves editing off. + if id == "edit" { + if self.mode != Mode::Read { + self.quick_tool = QuickTool::EditText; + } + } else if self.mode != Mode::Edit + && self.left == LeftPanel::Tool("edit") + && matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) + { + self.put_down_edit_text(); + } self.left = LeftPanel::Tool(id); self.reveal_tools(); if let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) { diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 9381664527..a545b4165a 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -161,6 +161,7 @@ impl PdfCraftApp { if let Some(Dialog::Marks(kind)) = self.dialog { self.marks_draft.opened(kind); } + dialogs::discard_inactive_password_drafts(self); } ("tools", _) if value == "full" => self.expand_all_tools(), ("tools", _) => { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 1a873a7c11..a5db36c6ad 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -156,7 +156,7 @@ impl PdfCraftApp { let mut queue = std::mem::take(&mut self.deferred_opens); while self.deferred_opens.is_empty() && self.pending_opens.len() < self.open_slots() { let Some(next) = queue.pop_front() else { break }; - self.open_path_request(&next.path, next.then, next.display_override); + self.open_file_request(&next.path, next.after_open, next.then, next.display_override); } // A file put back (the queue filled again) stays ahead of the ones behind it. self.deferred_opens.append(&mut queue); @@ -484,7 +484,9 @@ impl PdfCraftApp { } } - /// Save an attachment to disk, or open a PDF attachment in a new tab. + /// Save an attachment to disk, or open a PDF attachment in a new tab (or go to the tab it is + /// already open in). Only bytes that are a PDF open, whatever the file's name says; anything + /// else asked to open is offered to save. pub fn attachment_action(&mut self, doc: DocId, index: usize, open: bool) { let Some(d) = self.session.get(doc) else { return }; let Some(att) = d.info.attachments.get(index).cloned() else { return }; @@ -493,13 +495,30 @@ impl PdfCraftApp { let source = d.path.clone(); match (data, open) { (Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]), - (Ok(bytes), true) => { + (Ok(bytes), true) if looks_like_pdf(&bytes) => { // The tab name later names files written from the tab (Split, Extract pages). - if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]); + let name = crate::files::attachment_file_name(&att.name); + // Already open: the tab opened from this attachment of this document (its bytes + // may have grown since, as a form laid out on open appends its layout). + let origin = (doc, att.name.clone()); + let open_in = self.views.iter().position(|v| v.from_attachment.as_ref() == Some(&origin) && self.session.get(v.id).is_some()); + if let Some(i) = open_in { + self.active = Some(i); + return; + } + let before = self.views.len(); + match self.open_bytes(&name, None, bytes) { + // Opening bytes adds their tab at once (unless a password is asked for first). + Ok(()) if self.views.len() > before => { + if let Some(v) = self.views.last_mut() { + v.from_attachment = Some(origin); + } + } + Ok(()) => {} + Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]), } } - (Ok(bytes), false) => { + (Ok(bytes), _) => { #[cfg(not(target_arch = "wasm32"))] self.ask_one( pickers::Ask::Save(rfd::AsyncFileDialog::new().set_file_name(crate::files::attachment_file_name(&att.name))), @@ -627,11 +646,12 @@ impl PdfCraftApp { /// Answer the password prompt (`None` cancels). pub fn submit_password(&mut self, password: Option) { - let Some(p) = self.password_prompt.take() else { return }; - let Some(pw) = password else { + let Some(mut p) = self.password_prompt.take() else { return }; + zeroize::Zeroize::zeroize(&mut p.input); + let Some(pw) = password.map(zeroize::Zeroizing::new) else { // A cancelled recovery goes back on offer; its snapshot stays in the store and must // not attach itself to whatever document is unlocked next (#813). - if let Some(meta) = p.recovered { + if let Some(meta) = p.recovered.take() { self.recoverable.push(meta); } #[cfg(not(target_arch = "wasm32"))] @@ -669,7 +689,9 @@ impl PdfCraftApp { #[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) { + let opened = self.try_open(&p.name, p.path.take(), p.bytes.clone(), Some(&pw), p.activate); + drop(pw); + match opened { 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() => { @@ -706,7 +728,7 @@ impl PdfCraftApp { // and the tool waiting for it. Ok(()) => { if let Some(prompt) = self.password_prompt.as_mut() { - prompt.recovered = p.recovered; + prompt.recovered = p.recovered.take(); prompt.then = p.then; prompt.after_open = after_open; prompt.display_override = p.display_override; @@ -726,8 +748,11 @@ impl PdfCraftApp { /// Answer the permissions password prompt (`None` cancels). pub fn submit_unlock(&mut self, password: Option) { let Some(mut p) = self.unlock_prompt.take() else { return }; - let Some(pw) = password else { return }; - match self.session.unlock(p.doc, &pw) { + zeroize::Zeroize::zeroize(&mut p.input); + let Some(mut pw) = password else { return }; + let unlocked = self.session.unlock(p.doc, &pw); + zeroize::Zeroize::zeroize(&mut pw); + match unlocked { // Carry on with what asked for the password, if its document is still the one shown. Ok(()) => match p.then { Some(command) if self.active_ids().is_some_and(|(_, id)| id == p.doc) => { @@ -736,7 +761,6 @@ impl PdfCraftApp { _ => self.notify_tr("Restrictions lifted"), }, Err(pdfcraft_engine::EditError::WrongPassword) => { - p.input.clear(); p.error = Some("Incorrect password. Try again.".to_string()); self.unlock_prompt = Some(p); } @@ -778,6 +802,24 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] fn open_path_request(&mut self, path: &str, then: Option<&'static str>, display_override: Option<(canvas::PageLayout, bool)>) -> OpenOutcome { + // `manual.pdf#nameddest=Intro`, as a help system starts a PDF reader (#3425): the file, + // shown at that destination once open. A path that names a file is never split. + match pdfcraft_engine::open_parameters::split(path, |p| std::path::Path::new(p).is_file()) { + Some((file, destination)) => self.open_file_request(&file, Some(destination), then, display_override), + None => self.open_file_request(path, None, then, display_override), + } + } + + /// Open the file `path`, shown at `after_open` once open. A file that waits for room in the + /// worker's queue keeps its destination with it, as its path is the file alone (#3425). + #[cfg(not(target_arch = "wasm32"))] + fn open_file_request( + &mut self, + path: &str, + after_open: Option, + then: Option<&'static str>, + display_override: Option<(canvas::PageLayout, bool)>, + ) -> OpenOutcome { // One file, one tab (#2166): a file that is open already comes to the front instead, // and the tool it was opened for runs on it. One still being opened is left to that. if let Some(i) = self.tab_showing(path) { @@ -789,6 +831,9 @@ impl PdfCraftApp { view.set_display(layout, continuous); self.launch_display_target = Some(view.id); } + if let Some(destination) = after_open { + self.apply_destination_at(i, destination); + } if let Some(command) = then { self.execute(command); } @@ -798,14 +843,22 @@ impl PdfCraftApp { if display_override.is_some() { pending.display_override = display_override; } + // Asked for another place before it finished opening: it opens at the latest one. + if after_open.is_some() { + pending.after_open = after_open; + } return OpenOutcome::Pending; } // The same file named again while its password is being asked for joins that prompt - // instead of queuing a second one; a display override or a tool it brings is kept. + // instead of queuing a second one; a display override, a place to open at or a tool it + // brings is kept. if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) { if display_override.is_some() { prompt.display_override = display_override; } + if after_open.is_some() { + prompt.after_open = after_open; + } if then.is_some() { prompt.then = then; } @@ -815,6 +868,9 @@ impl PdfCraftApp { if display_override.is_some() { deferred.display_override = display_override; } + if after_open.is_some() { + deferred.after_open = after_open; + } if then.is_some() { deferred.then = then; } @@ -841,9 +897,10 @@ impl PdfCraftApp { if let Some(prompt) = self.password_prompt.as_mut().filter(|p| { p.path.as_deref() == Some(path) && previous_prompt.as_ref().is_none_or(|bytes| !std::sync::Arc::ptr_eq(bytes, &p.bytes)) }) { - // Encrypted: the tool runs once the password is in. + // Encrypted: the destination, the layout and the tool wait for the password. prompt.then = then; prompt.display_override = display_override; + prompt.after_open = after_open; } else if self.views.len() > before { if let Some((layout, continuous)) = display_override && let Some(view) = self.views.last_mut() @@ -854,6 +911,9 @@ impl PdfCraftApp { if let Some(doc) = self.views.last().map(|view| view.id) { self.watch.known_as(doc, stamp); } + if let Some(destination) = after_open { + self.apply_remote_destination(destination); + } if let Some(command) = then { self.execute(command); } @@ -874,7 +934,7 @@ impl PdfCraftApp { // The worker queue is bounded. A long file list (a launch behind a password prompt, // or many files at once) waits here, in order, and is submitted as opens finish. if self.pending_opens.len() >= self.open_slots() || !self.deferred_opens.is_empty() { - self.deferred_opens.push_back(DeferredOpen { path: path.to_string(), then, display_override }); + self.deferred_opens.push_back(DeferredOpen { path: path.to_string(), after_open, then, display_override }); return OpenOutcome::Pending; } match self.schedule_open(OpenRequest { @@ -884,7 +944,7 @@ impl PdfCraftApp { input: open_worker::Input::Path(std::path::PathBuf::from(path)), password: None, activate: true, - after_open: None, + after_open, recovered: None, then, }) { @@ -1023,3 +1083,8 @@ impl PdfCraftApp { } } } + +/// Whether `bytes` are a PDF: a `%PDF-` header within the first 1024 bytes, as readers accept. +fn looks_like_pdf(bytes: &[u8]) -> bool { + bytes.get(..1024).unwrap_or(bytes).windows(5).any(|w| w == b"%PDF-") +} diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index 419f7a9ed5..7894643a4b 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -1,4 +1,5 @@ use super::*; +use crate::page_keys::PageKeys; impl PdfCraftApp { pub(super) fn shortcuts(&mut self, ctx: &egui::Context) { @@ -31,57 +32,42 @@ impl PdfCraftApp { return; } // The in-window menu bar holds the keys while one of its menus is open or it has the - // keyboard (Alt, F10); a command shortcut leaves it and runs. - craft_menu::bar::leave_for_shortcut(ctx); - if !craft_menu::bar::is_open(ctx) && !craft_menu::bar::is_focused(ctx) { + // keyboard (Alt, F10); a command shortcut has left it already (`logic`) and runs. The + // chords wait meanwhile: the registered commands', Select all and the view's. + let chords = !craft_menu::bar::is_open(ctx) && !craft_menu::bar::is_focused(ctx); + // The pages' own chords (Select all, the view's) don't reach pages something covers: the + // full-screen and update prompts, or the OCR Verify screen in their place (a dialog has + // returned above, and the palette holds the keyboard). + let page_chords = !self.covers_pages(); + if chords { self.registry_shortcuts(ctx); } // (Esc closes a Combine file shown large first.) - if self.full_screen && self.combine_tab.preview.is_none() && ctx.input(|i| i.key_pressed(Key::Escape)) { + if self.full_screen && self.combine_tab.preview.is_none() && self.page_keys.pressed(ctx, Key::Escape) { self.set_full_screen(ctx, false); } if let Some(i) = self.active { - // Select all belongs to the document or page grid, unless a text field or - // overlay owns the keyboard. Other canvas shortcuts keep their own handling. - let pages_owns_keyboard = - self.right == Some(RightPanel::Pages) && self.views[i].pages_focus_id.is_some_and(|id| ctx.memory(|m| m.has_focus(id))); - if self.dialog.is_none() - && !self.palette_open - && (pages_owns_keyboard || !ctx.egui_wants_keyboard_input()) - && ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A))) - { - let view = &self.views[i]; - // A paragraph or text box open for typing keeps ⌘A for its own text. - let edit_objects = self.quick_tool == QuickTool::EditText && view.line_editor.is_none() && view.content.draft.is_none(); - if pages_owns_keyboard { - self.views[i].select_all_pages(); - } else if let Some(doc) = self.session.get(view.id).filter(|d| edit_objects && d.allows_modification()) { - object_ui::select_all(&mut self.views[i], doc); - } else if self.is_preparing() { - if let Some(doc) = self.session.get(self.views[i].id) { - prepare::select_all(&mut self.views[i], &doc.form); - } - } else { - self.views[i].select_all(); - } + if page_chords && chords { + self.select_all_keys(i, ctx); } self.tool_keys(i, ctx); + let keys = self.page_keys; if self.quick_tool == QuickTool::EditText && let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification()) { - object_ui::nudge_keys(ctx, &mut self.views[i], doc); - object_ui::delete_keys(ctx, &mut self.views[i]); + object_ui::nudge_keys(ctx, keys, &mut self.views[i], doc); + object_ui::delete_keys(ctx, keys, &mut self.views[i]); } else if let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_annotation()) { - comments::nudge_keys(ctx, &mut self.views[i], &doc.info); + comments::nudge_keys(ctx, keys, &mut self.views[i], &doc.info); } // A selected field moves with the arrows too (Prepare a form, #2608), before the view // turns the page with them. if self.is_preparing() && let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification()) { - prepare::nudge_keys(ctx, &mut self.views[i], doc); + prepare::nudge_keys(ctx, keys, &mut self.views[i], doc); } - canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed); + canvas::shortcuts(&mut self.views[i], ctx, keys, chords && page_chords, self.scroll_speed); self.clipboard_keys(i, ctx); self.image_pick_keys(i, ctx); } @@ -89,13 +75,43 @@ impl PdfCraftApp { } impl PdfCraftApp { + /// ⌘A selects all of the document or the page grid, unless a text field or overlay owns the + /// keyboard. + fn select_all_keys(&mut self, i: usize, ctx: &egui::Context) { + use egui::Key; + let pages_owns_keyboard = + self.right == Some(RightPanel::Pages) && self.views[i].pages_focus_id.is_some_and(|id| ctx.memory(|m| m.has_focus(id))); + if self.dialog.is_none() + && !self.palette_open + && (pages_owns_keyboard || !ctx.egui_wants_keyboard_input()) + && ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A))) + { + let view = &self.views[i]; + // A paragraph or text box open for typing keeps ⌘A for its own text. + let edit_objects = self.quick_tool == QuickTool::EditText && view.line_editor.is_none() && view.content.draft.is_none(); + if pages_owns_keyboard { + self.views[i].select_all_pages(); + } else if let Some(doc) = self.session.get(view.id).filter(|d| edit_objects && d.allows_modification()) { + object_ui::select_all(&mut self.views[i], doc); + } else if self.is_preparing() { + if let Some(doc) = self.session.get(self.views[i].id) { + prepare::select_all(&mut self.views[i], &doc.form); + } + } else { + self.views[i].select_all(); + } + } + } + /// The quick tools' keys, as in Acrobat: V selects, H pans, and holding Space pans until it - /// is released. Plain letters, so not while a text field, a form field, a dialog or the - /// palette has the keyboard. + /// is released. Plain letters, so not while a text field, a form field, a dialog, the palette + /// or a menu has the keyboard. fn tool_keys(&mut self, i: usize, ctx: &egui::Context) { use egui::Key; + let keys = self.page_keys; let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input() && self.views[i].forms.focus.is_none(); - let (plain, space) = ctx.input(|input| (input.modifiers.is_none(), input.key_down(Key::Space))); + let free = free && !keys.over(); + let (plain, space) = (ctx.input(|input| input.modifiers.is_none()), PageKeys::held(ctx, Key::Space)); if let Some(previous) = self.space_hand && !space { @@ -107,11 +123,11 @@ impl PdfCraftApp { if !free || !plain { return; } - if ctx.input(|input| input.key_pressed(Key::V)) { + if keys.pressed(ctx, Key::V) { self.quick_tool = QuickTool::Select; self.space_hand = None; } - if ctx.input(|input| input.key_pressed(Key::H)) { + if keys.pressed(ctx, Key::H) { self.quick_tool = QuickTool::Hand; self.space_hand = None; } diff --git a/crates/ui-egui/src/app/links.rs b/crates/ui-egui/src/app/links.rs index 77f9519972..028560b17b 100644 --- a/crates/ui-egui/src/app/links.rs +++ b/crates/ui-egui/src/app/links.rs @@ -66,13 +66,69 @@ impl PdfCraftApp { } pub(super) fn apply_remote_destination(&mut self, destination: pdfcraft_render::RemoteDestination) { - let Some(index) = self.views.len().checked_sub(1) else { return }; - let Some(doc) = self.session.get(self.views[index].id) else { return }; + if let Some(index) = self.views.len().checked_sub(1) { + self.apply_destination_at(index, destination); + } + } + + /// Show `destination` in the tab at `index`: a document just opened from a link or with open + /// parameters (#3425), or one already open that such a link or launch names. The latest request + /// for a document wins over a named destination still being looked up. + pub(super) fn apply_destination_at(&mut self, index: usize, destination: pdfcraft_render::RemoteDestination) { + let Some(id) = self.views.get(index).map(|view| view.id) else { return }; + #[cfg(not(target_arch = "wasm32"))] + self.pending_destinations.retain(|pending| pending.doc != id); + let destination = match destination { + pdfcraft_render::RemoteDestination::Named(name) => { + let Some(doc) = self.session.get(id) else { return }; + let (bytes, password) = (doc.bytes.clone(), doc.password.clone()); + // Live, the name tree is read off the UI thread; startup and tests stay inline. + #[cfg(not(target_arch = "wasm32"))] + if let Some(ctx) = self.ctx.clone().filter(|_| !self.run_inline) { + let (sender, receiver) = std::sync::mpsc::channel(); + let (work_bytes, work_password, work_name) = (bytes.clone(), password.clone(), name.clone()); + let work = move || { + let _ = sender.send(pdfcraft_render::named_destination(work_bytes, work_password.as_deref(), &work_name)); + ctx.request_repaint(); + }; + if std::thread::Builder::new().name("pdfcraft-named-destination".into()).spawn(work).is_ok() { + self.pending_destinations.push(PendingDestination { doc: id, name, receiver }); + return; + } + } + found_destination(pdfcraft_render::named_destination(bytes, password.as_deref(), &name), name) + } + other => other, + }; + self.show_destination_at(index, destination); + } + + /// Show each named destination whose lookup has finished, in its document's tab. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn poll_pending_destinations(&mut self) { + for pending in std::mem::take(&mut self.pending_destinations) { + let found = match pending.receiver.try_recv() { + Ok(found) => found, + Err(std::sync::mpsc::TryRecvError::Empty) => { + self.pending_destinations.push(pending); + continue; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => None, + }; + if let Some(index) = self.views.iter().position(|view| view.id == pending.doc) { + self.show_destination_at(index, found_destination(found, pending.name)); + } + } + } + + fn show_destination_at(&mut self, index: usize, destination: pdfcraft_render::RemoteDestination) { + let Some(view) = self.views.get_mut(index) else { return }; + let Some(doc) = self.session.get(view.id) else { return }; let warning = match destination { pdfcraft_render::RemoteDestination::Default => None, pdfcraft_render::RemoteDestination::Named(_) => Some("The linked PDF opened, but its named destination was not applied."), - pdfcraft_render::RemoteDestination::Page { page, view } if page < doc.info.pages.len() => { - self.views[index].go_to_dest(page, view, &doc.info); + pdfcraft_render::RemoteDestination::Page { page, view: dest } if page < doc.info.pages.len() => { + view.go_to_dest(page, dest, &doc.info); None } pdfcraft_render::RemoteDestination::Page { .. } => Some("The linked PDF opened, but its page destination was out of range."), @@ -123,3 +179,12 @@ impl PdfCraftApp { } } } + +/// A named destination's lookup result as a destination to show: its page, or the name again when +/// the document doesn't define it, which shows a warning. +fn found_destination(found: Option<(usize, pdfcraft_render::DestView)>, name: String) -> pdfcraft_render::RemoteDestination { + match found { + Some((page, view)) => pdfcraft_render::RemoteDestination::Page { page, view }, + None => pdfcraft_render::RemoteDestination::Named(name), + } +} diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 67604292bb..04f6958ff2 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -189,6 +189,8 @@ impl PdfCraftApp { v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)); // Also set outside json! above, which is at serde_json's macro recursion limit. v["print"] = print; + // Each printer's paper, sizing and two-sided choice, the one printed to last first (#2001). + v["print_printers"] = crate::print_ui::setups_to_json(&self.print_draft.setups); v.to_string() } @@ -316,6 +318,9 @@ impl PdfCraftApp { { self.print_draft.paper = paper; } + if let Some(setups) = crate::print_ui::setups_from_json(&v["print_printers"]) { + self.print_draft.setups = setups; + } if let Some(on) = v["highlight_fields"].as_bool() { self.view_defaults.highlight_fields = on; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8ad7a9f932..452f5f231f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -30,6 +30,7 @@ mod navigation; mod organize; pub(super) mod page_cursor; mod page_input; +pub(super) mod page_keys; pub(super) mod page_pointer; pub(super) mod page_router; mod presenter; @@ -59,7 +60,7 @@ use cache::{ use cache::{OLD_TILES, PageTex, STALE_TAG, TEXT_TAG, TILE_BYTES, scale_tag, tag_scale, texture_bytes, tile_side}; pub use find::Find; use find::find_bar; -pub use navigation::shortcuts; +pub(crate) use navigation::shortcuts; pub(crate) use organize::drop_gap; #[cfg(test)] pub(crate) use organize::grid_page_rect; @@ -102,39 +103,15 @@ impl PainterExt for egui::Painter { } } -/// User-space selectable bounding box for an image object. -/// If the image object has an active clipping path applied to it (the crop box), -/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect. +/// User-space editing frame: explicit PDF/editor crops apply, but the page edge does not +/// truncate a selected image's handles. Body hit testing still respects page visibility. pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] { - if let Some(clip) = im.clip_rect { - let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])]; - if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] { - return r; - } - } - im.visible_rect.unwrap_or(im.rect) + im.frame_rect().unwrap_or(im.rect) } -/// Screen-space selectable bounding box for an image object. -/// If the image object has an active clipping path applied to it (the crop box), -/// the final selectable `egui::Rect` is the mathematical intersection of the image's -/// full CTM-derived rect and the clipping path's rect. +/// Screen-space editing frame, including selected handles outside the page. pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect { - let full = xf.user_rect_f64(info, page, im.rect); - if let Some(clip) = im.clip_rect { - let clip_r = xf.user_rect_f64(info, page, clip); - let inter = full.intersect(clip_r); - if inter.is_positive() { - return inter; - } - } - if let Some(vr) = im.visible_rect { - let r = xf.user_rect_f64(info, page, vr); - if r.is_positive() { - return r; - } - } - full + xf.user_rect_f64(info, page, image_selectable_bounds(im)) } /// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space, @@ -507,6 +484,9 @@ pub struct DocView { /// views of one document apart (`crate::panes`). pub(crate) pane: crate::panes::ViewPane, pub id: DocId, + /// The document and attached file this tab was opened from, when a form or the Attachments + /// panel opened it: asking for that attachment again comes back to this tab. + pub from_attachment: Option<(DocId, String)>, pub zoom: f32, pub fit: Fit, pub layout: PageLayout, @@ -1040,6 +1020,7 @@ impl DocView { Self { pane: Default::default(), id, + from_attachment: None, zoom: defaults.zoom, fit: defaults.fit, layout: defaults.layout, @@ -1162,7 +1143,7 @@ impl DocView { page_widget: None, pan_hold_until: 0.0, slow_pages: HashSet::new(), - comments: Default::default(), + comments: crate::comments::CommentView::from_info(info), measure: Default::default(), forms: Default::default(), prepare: Default::default(), @@ -1226,6 +1207,7 @@ impl DocView { // Thumbnail drags hold page indexes from before the change. self.panel_drag = None; self.org_drag = None; + self.comments.document_changed(info); self.reset_coarse_state(); } @@ -1374,6 +1356,8 @@ struct PageEnv<'a> { initials: &'a Option, key_scroll: f32, key_side: f32, + /// This frame's keys as the pages see them. + keys: crate::page_keys::PageKeys, middle_gesture: bool, modal: bool, navigation_enabled: bool, @@ -1726,7 +1710,7 @@ fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document } fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i: usize) { - let PageEnv { allowed, comments_hidden, doc, editing_content, info, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env; + let PageEnv { allowed, comments_hidden, doc, editing_content, info, keys, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env; let PageFrame { ptr, origin, visible, pressed, pointer, .. } = *frame; let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); if !r.intersects(visible.expand(400.0)) { @@ -1767,7 +1751,7 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i if frame.env.text_links { view.refresh_text_links(i); } - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark, keys }; // A cancelled press takes nothing until the button is up, though the page's menu still opens // where a right-click is. A page full screen doesn't show yet takes nothing at all. (A press // there gives nothing on the page an owner, so its release, once the page is in, takes nothing @@ -1870,7 +1854,12 @@ fn draw_pages( if delta != Vec2::ZERO && delta.is_finite() { 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 resized handle can sit in the window beside a page that does not fill it. The + // response has to cover that visible area or the next press never starts: the pointer + // is reported only inside this rectangle once the previous drag has been released. + // Page positions stay on `content_w`/`content_h`; the extra area is not a new page. + let response_size = vec2(content_w.max(viewport.width()), content_h.max(viewport.height())); + let (resp_rect, resp) = ui.allocate_exact_size(response_size, Sense::click_and_drag()); view.page_widget = Some(resp.id); // A drag lasts only while egui reports it, so the mark can't outlive one. if !resp.dragged_by(egui::PointerButton::Primary) { @@ -2817,8 +2806,9 @@ fn scroll_direction(dir: i8, dy: f32, resized: bool) -> i8 { /// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the /// text editor was finished. fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, primary_down: bool, hover_text: Option<(Pos2, String)>) -> bool { - let PageEnv { added, allowed, author, avail, can_modify, comments_hidden, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = - *env; + let PageEnv { + added, allowed, author, avail, can_modify, comments_hidden, doc, form, info, keys, prefs, t, today, tool, units, unobstructed, .. + } = *env; if let Some((pos, text)) = hover_text { // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false); @@ -2834,7 +2824,7 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim if tool == QuickTool::EnhanceCamera { crate::camera_ui::bar(ui.ctx(), view, avail, t, can_modify); } - if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) { + if let Some(e) = comments::composer(ui.ctx(), keys, view, info, prefs) { view.pending_edit = Some(e); } if let Some(e) = comments::popups(ui.ctx(), view, info, prefs, comments_hidden, allowed) { @@ -2845,17 +2835,17 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim None => e, }); } - if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { + if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), keys, view, info) { view.pending_edit = Some(e); } let text_box = crate::forms_ui::text_box(doc, view, form); - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, primary_down, text_box.as_ref(), today) { + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), keys, view, info, form, primary_down, text_box.as_ref(), today) { view.pending_edit = Some(e); } - if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, author) { + if let Some(e) = crate::fill_sign::type_box(ui.ctx(), keys, view, info, author) { view.pending_edit = Some(e); } - let (typed, text_done) = crate::content_ui::editor(ui.ctx(), view, info, added); + let (typed, text_done) = crate::content_ui::editor(ui.ctx(), keys, view, info, added); if typed.is_some() { view.pending_edit = typed; } @@ -3244,13 +3234,16 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // No dialog, close prompt or palette over the page: only then does page input count. let modal = app.modal_open(); let unobstructed = !modal && !app.palette_open; + // A menu click can open a dialog after the frame's first look. + app.page_keys = app.page_keys.also(app.covers_pages()); + let keys = app.page_keys; let organize_zoom = app.organize_zoom; let invert_middle = app.invert_middle_drag; let view = &mut app.views[index]; if view.organize { // One remembered grid size for every document, kept in the settings. view.set_grid_zoom(organize_zoom); - organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, invert_middle, ui, &t); + organize_grid(view, doc, unobstructed, keys, app.show_page_edge_shadows, invert_middle, ui, &t); app.organize_zoom = view.grid_zoom(); return; } @@ -3355,7 +3348,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { view.prepare.selected = None; } - view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx()); + view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx(), keys); view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), view.content.draft.as_ref(), scale); let added = if editing_content { doc.added().to_vec() } else { Vec::new() }; let doc_links = if tool == QuickTool::Link { doc.links().to_vec() } else { Vec::new() }; @@ -3403,6 +3396,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { initials: &initials, key_scroll, key_side, + keys, middle_gesture, modal, navigation_enabled, @@ -3509,19 +3503,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // One crop, then back to selecting (as Acrobat does). let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, pdfcraft_engine::Edit::SetPageBox { .. })); let mut tool = app.quick_tool; - comments::keys(ui.ctx(), view, &mut tool, allowed); + comments::keys(ui.ctx(), keys, view, info, &mut tool, allowed); if preparing { - crate::prepare::keys(ui.ctx(), view); + crate::prepare::keys(ui.ctx(), keys, view); } if tool != QuickTool::EditText { view.image_selection = None; crate::content_ui::release_image_selection(view, &added); } if editing_content { - crate::content_ui::keys(ui.ctx(), view, tool == QuickTool::EditText); + crate::content_ui::keys(ui.ctx(), keys, view, tool == QuickTool::EditText); } if tool == QuickTool::Link { - crate::link_ui::keys(ui.ctx(), view); + crate::link_ui::keys(ui.ctx(), keys, view); } else { view.links.selected = None; } @@ -3844,7 +3838,7 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459); // the quick tools come back with the window. // A document that hides the toolbars (`HideToolbar`) hides this one too. - if app.mode != crate::Mode::Read && !app.full_screen && !app.views.get(index).is_some_and(|v| v.interface.hidden.toolbar) { + if !app.hotbar_hidden() && !app.views.get(index).is_some_and(|v| v.interface.hidden.toolbar) { // The page nearest the left edge, whose margin the bar sits in. let page_left = app.views[index].screen_rects.iter().map(|(_, r)| r.left()).reduce(f32::min); quick_bar(app, avail, page_left, ui); diff --git a/crates/ui-egui/src/canvas/image_pick.rs b/crates/ui-egui/src/canvas/image_pick.rs index 81ae3ba757..d36bfdd88e 100644 --- a/crates/ui-egui/src/canvas/image_pick.rs +++ b/crates/ui-egui/src/canvas/image_pick.rs @@ -422,7 +422,7 @@ impl PdfCraftApp { if ctx.egui_wants_keyboard_input() { return; } - if ctx.input(|inp| inp.key_pressed(egui::Key::Escape)) { + if self.page_keys.pressed(ctx, egui::Key::Escape) { if let Some(view) = self.views.get_mut(i) { view.picked_image = None; view.pending_pick = None; @@ -430,7 +430,7 @@ impl PdfCraftApp { return; } let Some(picked) = self.views.get(i).and_then(|v| v.picked_image) else { return }; - let copy = ctx.input(|inp| inp.events.iter().any(|e| matches!(e, egui::Event::Copy))); + let copy = self.page_keys.clipboard(ctx).copy; if !copy || self.views[i].selected_text().is_some() { return; } diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 47196ca140..0fdb2d3bfd 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -379,16 +379,33 @@ fn zoom_shortcuts(view: &mut DocView, pressed: &impl Fn(egui::KeyboardShortcut) } } -/// The document view's keyboard shortcuts: find, rotation, zoom (or the page grid's size), -/// history, Escape, and the paging and scrolling keys. An open form field keeps the paging -/// and scrolling keys for itself. -pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_speed::ScrollSpeed) { +/// The view's keys: its chords, then its plain keys. `chords`: no menu of the menu bar has the +/// keyboard and nothing covers the pages, so chords may run (they wait for a menu, as the +/// registered commands' do). An open form field keeps the paging and scrolling keys for itself. +pub(crate) fn shortcuts( + view: &mut DocView, + ctx: &egui::Context, + keys: crate::page_keys::PageKeys, + chords: bool, + speed: crate::scroll_speed::ScrollSpeed, +) { + if let Some((arrows, field_open)) = view_chords(view, ctx, chords) { + view_keys(view, ctx, keys, speed, arrows, field_open); + } +} + +/// The view's chords: Find and its steps, rotation, zoom and the view history, unless a menu of the +/// menu bar has the keyboard or something covers the pages (`chords`). `None` while a text field +/// has the keyboard, and then it has the plain keys too; else whether the arrows are the view's (a +/// focused radio button keeps them), and whether an open form field (a drop-down's or a list box's +/// items) keeps the plain keys. +fn view_chords(view: &mut DocView, ctx: &egui::Context, chords: bool) -> Option<(bool, bool)> { use egui::{Key, KeyboardShortcut, Modifiers}; - if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) { + let pressed = |s: KeyboardShortcut| chords && ctx.input_mut(|i| i.consume_shortcut(&s)); + if pressed(KeyboardShortcut::new(Modifiers::COMMAND, Key::F)) { view.open_find(); } let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k); - let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s)); // ⌘G / ⇧⌘G step through the matches from the find field too, as in Acrobat and Preview // (#2669): ahead of the text-input check, but only while there is a search to step. if view.find.is_some() { @@ -402,7 +419,7 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s // radio button for the arrows that move within its group: the other page keys still work. let form_box = view.forms.keys.filter(|(id, _)| ctx.memory(|m| m.focused()) == Some(*id)); if ctx.egui_wants_keyboard_input() && form_box.is_none() { - return; + return None; } let arrows = !form_box.is_some_and(|(_, radio)| radio); // Rotation first: egui matches ⌘+ loosely with respect to Shift. @@ -418,11 +435,26 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s if pressed(cmd(Key::CloseBracket)) || pressed(alt(Key::ArrowRight)) { view.view_history(true); } - let key = |k| (arrows || !matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown)) && ctx.input(|i| i.key_pressed(k)); // An open form field (a drop-down's or a list box's items) takes the arrows, Page Up / Page // Down, Home, End and Escape. A focused check box or radio button uses none of them, so the // page keys still work there. let field_open = view.forms.focus.is_some() && form_box.is_none(); + Some((arrows, field_open)) +} + +/// The view's plain keys: Escape, Home and End, full screen's keys, the arrows and Page Up / Page +/// Down. Not while something is over the pages or a form field is open (`field_open`), which keeps +/// them. +fn view_keys( + view: &mut DocView, + ctx: &egui::Context, + keys: crate::page_keys::PageKeys, + speed: crate::scroll_speed::ScrollSpeed, + arrows: bool, + field_open: bool, +) { + use egui::Key; + let key = |k| (arrows || !matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown)) && keys.pressed(ctx, k); // An open menu takes Escape first and closes; the selection stays for the next one (#3535). // So does an open field, whose list closes. if key(Key::Escape) && !egui::Popup::is_any_open(ctx) && !field_open { @@ -443,9 +475,16 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s // (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down (#6434; *Adobe Acrobat Help*, 2019, // "Keys for navigating a PDF", p. 83). These take the key from the page turning and // scrolling below. - let shift_cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k); - let to_start = pressed(shift_cmd(Key::ArrowUp)) || pressed(shift_cmd(Key::PageUp)); - let to_end = pressed(shift_cmd(Key::ArrowDown)) || pressed(shift_cmd(Key::PageDown)); + let (mut to_start, mut to_end) = (false, false); + keys.take_presses(ctx, |k, m| { + let shift_cmd = m.matches_logically(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + match k { + Key::ArrowUp | Key::PageUp if shift_cmd => to_start = true, + Key::ArrowDown | Key::PageDown if shift_cmd => to_end = true, + _ => return false, + } + true + }); if key(Key::Home) || to_start { view.go_to_page(0); } diff --git a/crates/ui-egui/src/canvas/organize.rs b/crates/ui-egui/src/canvas/organize.rs index 25c99543e4..2917ffdcae 100644 --- a/crates/ui-egui/src/canvas/organize.rs +++ b/crates/ui-egui/src/canvas/organize.rs @@ -4,6 +4,7 @@ use super::{ Align2, Color32, CornerRadius, DocInfo, DocView, Edit, Pos2, Rect, RenderRequest, RequestKind, Sense, Stroke, Tokens, Vec2, ViewAction, device_pixels, icons, pos2, theme, vec2, widgets, }; +use crate::page_keys::PageKeys; /// How far a sharp grid render may be from the size drawn before it is redone: a pinch changes /// the size on every frame, and a slightly soft or slightly large image is fine meanwhile. @@ -92,7 +93,7 @@ pub(crate) fn grid_page_rect(cell: Rect, page: Vec2) -> Rect { } /// The organize toolbar: page operations on the selection (Acrobat's Organize Pages bar). -fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) { +fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, keys: PageKeys, ui: &mut egui::Ui, t: &Tokens) { let targets = view.target_pages(); let n = info.pages.len(); let (first, last) = (targets.first().copied().unwrap_or(0), targets.last().copied().unwrap_or(0)); @@ -153,19 +154,12 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b }); // Keys act on the selection unless a text field or a dialog has them (Delete under // Preferences deleted the selected pages). - if editable && unobstructed && !ui.ctx().egui_wants_keyboard_input() { - use egui::{Key, Modifiers}; - let (del, esc) = ui.input_mut(|i| { - ( - i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace), - i.consume_key(Modifiers::NONE, Key::Escape), - ) - }); + if editable && !ui.ctx().egui_wants_keyboard_input() { + use egui::Key; + let ctx = ui.ctx(); + let (del, esc) = (keys.take(ctx, Key::Delete) || keys.take(ctx, Key::Backspace), keys.take(ctx, Key::Escape)); // ⌘C / ⌘X / ⌘V copy, cut and paste pages (egui delivers them as clipboard events). - let (copy, cut, paste) = ui.input(|i| { - let any = |f: &dyn Fn(&egui::Event) -> bool| i.events.iter().any(f); - (any(&|e| matches!(e, egui::Event::Copy)), any(&|e| matches!(e, egui::Event::Cut)), any(&|e| matches!(e, egui::Event::Paste(_)))) - }); + let crate::page_keys::Clipboard { copy, cut, paste } = keys.clipboard(ctx); if copy || cut { view.pending_action = Some(ViewAction::CopyPages { cut }); } @@ -267,10 +261,12 @@ fn request_sharper( } } +#[allow(clippy::too_many_arguments)] pub(super) fn organize_grid( view: &mut DocView, doc: &pdfcraft_engine::Document, unobstructed: bool, + keys: PageKeys, show_page_edge_shadows: bool, invert_middle: bool, ui: &mut egui::Ui, @@ -285,7 +281,7 @@ pub(super) fn organize_grid( let anchor = view.grid_anchor.take(); let reveal = view.grid_reveal.take(); let mut open_page = None; - organize_toolbar(view, info, editable, dirty, unobstructed, ui, t); + organize_toolbar(view, info, editable, dirty, keys, ui, t); let viewport = ui.available_rect_before_wrap(); view.viewport_screen = viewport; let auto_delta = if unobstructed { diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index fa0a15562b..b39c16fde0 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -10,6 +10,7 @@ use super::{ signature_badges, verdict_mark_rect, }; use crate::page_cursor::{Layer, Subject}; +use crate::page_keys::PageKeys; use crate::page_pointer::PagePointer; use crate::page_router::{Owner, PagePress, top}; use crate::{QuickTool, comments, icons}; @@ -45,6 +46,8 @@ pub(crate) struct PageToolContext<'a> { pub page: usize, pub doc: &'a pdfcraft_engine::Document, pub info: &'a DocInfo, + /// This frame's keys as the pages see them. + pub keys: PageKeys, } /// The tools' input on one page. The hits at the press point decide who holds the press, and the @@ -64,6 +67,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut editing_content, form, info, + keys, placing, prefs, preparing, @@ -76,8 +80,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } = frame.env; let PageSlot { i, xf, .. } = *slot; let PageFrame { resp, ptr, pointer, .. } = *frame; - let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info, keys }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark, keys }; let (lines, images) = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; let field_tool = match tool { @@ -210,7 +214,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // the palette, another window or another tab has it (`gate`), as before. let gate = !(tool == QuickTool::EditText && view.objects.input_blocked()); if tool == QuickTool::EditText && can_modify && gate { - crate::edit_text_ui::picture_keys(ui.ctx(), view, i, &images, ptr.any_down); + crate::edit_text_ui::picture_keys(ui.ctx(), keys, view, i, &images, ptr.any_down); } let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, press, view); let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); @@ -346,6 +350,7 @@ fn tool_input( doc, info, initials, + keys, signature, tool, units, @@ -356,7 +361,7 @@ fn tool_input( } = frame.env; let PageSlot { i, xf, .. } = *slot; let PageFrame { resp, ptr, .. } = *frame; - let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; + let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info, keys }; let free = press == PagePress::Free; if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, crate::measure_ui::Clicks::of(resp, free), doc, view, pcx, measure_tool, units); @@ -892,6 +897,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P form, generation, info, + keys, prepare_tab_order, prefs, preparing, @@ -905,8 +911,8 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P let PageSlot { i, r, xf } = *slot; let PageFrame { resp, ptr, pointer, .. } = *frame; let painter = ui.painter(); - let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info, keys }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark, keys }; // A press nobody holds picks the picture under it: a link's click follows the link only (ruling // 5), though its drag or double-click still lets the picked picture go, as does a drag the page // it is over first sees, whichever page it began on. diff --git a/crates/ui-egui/src/canvas/page_keys.rs b/crates/ui-egui/src/canvas/page_keys.rs new file mode 100644 index 0000000000..c70188e6cc --- /dev/null +++ b/crates/ui-egui/src/canvas/page_keys.rs @@ -0,0 +1,173 @@ +//! The keyboard as the pages see it. +//! +//! A dialog, the palette or a menu over the pages has the keys, but the pages are drawn before the +//! dialogs and read egui's keys first. So page modules read keys through [`PageKeys`], which has +//! none while something is over the pages: Delete under the Keyboard shortcuts window deleted the +//! comment selected behind it, and the Escape that closed it deselected the comment too. +//! +//! Each module still asks for itself whether a text field has the keyboard. + +use egui::{Context, Event, Key, Modifiers}; + +/// A frame's Copy, Cut and Paste, as egui delivers them (⌘C, ⌘X, ⌘V, or a menu's). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Clipboard { + pub copy: bool, + pub cut: bool, + pub paste: bool, +} + +/// One frame's keyboard, as the pages see it: their keys, or none while something is over them. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct PageKeys { + /// A dialog, the palette or a menu is over the pages. + over: bool, +} + +impl PageKeys { + pub(crate) fn new(over: bool) -> Self { + Self { over } + } + + /// The keys once something more is found over the pages this frame. Only ever more covered: a + /// menu that this frame's Escape closes still had that Escape. + #[must_use] + pub(crate) fn also(self, over: bool) -> Self { + Self { over: self.over || over } + } + + /// `key` was pressed for the pages this frame. It stays for whatever reads it next. + pub(crate) fn pressed(self, ctx: &Context, key: Key) -> bool { + !self.over && ctx.input(|i| i.key_pressed(key)) + } + + /// Take `key`, pressed without ⌘ or Ctrl (egui lets an extra Shift or Alt through), for the + /// pages: nothing after reads it. + pub(crate) fn take(self, ctx: &Context, key: Key) -> bool { + !self.over && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, key)) + } + + /// Take the frame's key presses that `take` wants for the pages, each judged by its own + /// modifiers (the frame's can differ when a key is let go in the same frame). `take` says + /// whether it took the press; a press taken is gone for whatever reads keys next. + pub(crate) fn take_presses(self, ctx: &Context, mut take: impl FnMut(Key, Modifiers) -> bool) { + if self.over { + return; + } + ctx.input_mut(|i| { + i.events.retain(|e| match e { + Event::Key { key, pressed: true, modifiers, .. } => !take(*key, *modifiers), + _ => true, + }); + }); + } + + /// The frame's Copy, Cut and Paste for the pages: none while something is over them. They stay + /// for whatever reads them next. + pub(crate) fn clipboard(self, ctx: &Context) -> Clipboard { + if self.over { + return Clipboard::default(); + } + ctx.input(|i| { + let any = |f: fn(&Event) -> bool| i.events.iter().any(f); + Clipboard { copy: any(|e| matches!(e, Event::Copy)), cut: any(|e| matches!(e, Event::Cut)), paste: any(|e| matches!(e, Event::Paste(_))) } + }) + } + + /// Something is over the pages: their keys wait. + pub(crate) fn over(self) -> bool { + self.over + } + + /// `key` is held down. Not the pages' alone: a key let go while something is over them is let + /// go all the same. + pub(crate) fn held(ctx: &Context, key: Key) -> bool { + ctx.input(|i| i.key_down(key)) + } +} + +#[cfg(test)] +mod tests { + use egui::RawInput; + + use super::*; + + fn press(key: Key) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE } + } + + /// Run one frame with `events`, calling `f` with its context. + fn frame(events: Vec, f: impl FnOnce(&Context)) { + let ctx = Context::default(); + let mut f = Some(f); + let mut output = ctx.run_ui(RawInput { events, ..Default::default() }, |ui| { + if let Some(f) = f.take() { + f(ui.ctx()); + } + }); + // An input-only test has no renderer to take the font texture. + output.textures_delta.clear(); + } + + #[test] + fn something_found_over_the_pages_stays_over_them_for_the_frame() { + assert_eq!(PageKeys::new(false).also(true), PageKeys::new(true)); + assert_eq!(PageKeys::new(true).also(false), PageKeys::new(true)); + assert_eq!(PageKeys::new(false).also(false), PageKeys::new(false)); + } + + #[test] + fn with_something_over_the_pages_they_have_no_keys_and_leave_them_there() { + frame(vec![press(Key::Delete)], |ctx| { + let keys = PageKeys::new(true); + assert!(!keys.pressed(ctx, Key::Delete)); + assert!(!keys.take(ctx, Key::Delete)); + assert!(ctx.input(|i| i.key_pressed(Key::Delete)), "the key stays for the dialog"); + }); + } + + #[test] + fn the_pages_take_the_presses_they_want_with_their_own_modifiers() { + let shifted = Event::Key { key: Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }; + frame(vec![press(Key::ArrowRight), shifted, Event::Copy, press(Key::Escape)], |ctx| { + let mut seen = Vec::new(); + PageKeys::new(true).take_presses(ctx, |key, _| { + seen.push(key); + true + }); + assert!(seen.is_empty(), "nothing while something is over the pages"); + PageKeys::new(false).take_presses(ctx, |key, modifiers| { + seen.push(key); + key == Key::ArrowLeft && modifiers.shift + }); + assert_eq!(seen, [Key::ArrowRight, Key::ArrowLeft, Key::Escape]); + assert!(!ctx.input(|i| i.key_pressed(Key::ArrowLeft)), "the press taken is gone"); + assert!(ctx.input(|i| i.key_pressed(Key::ArrowRight) && i.key_pressed(Key::Escape)), "the others stay"); + assert!(ctx.input(|i| i.events.contains(&Event::Copy)), "and so does what isn't a key"); + }); + } + + #[test] + fn the_clipboard_is_the_pages_only_with_nothing_over_them() { + frame(vec![Event::Copy, Event::Paste("text".into())], |ctx| { + assert_eq!(PageKeys::new(true).clipboard(ctx), Clipboard::default()); + assert_eq!(PageKeys::new(false).clipboard(ctx), Clipboard { copy: true, cut: false, paste: true }); + }); + frame(vec![Event::Cut], |ctx| { + assert_eq!(PageKeys::new(true).clipboard(ctx), Clipboard::default()); + assert_eq!(PageKeys::new(false).clipboard(ctx), Clipboard { copy: false, cut: true, paste: false }); + }); + } + + #[test] + fn the_pages_peek_at_a_key_or_take_it() { + frame(vec![press(Key::Delete)], |ctx| { + let keys = PageKeys::new(false); + assert!(keys.pressed(ctx, Key::Delete)); + assert!(keys.pressed(ctx, Key::Delete), "a peek leaves it"); + assert!(keys.take(ctx, Key::Delete)); + assert!(!keys.pressed(ctx, Key::Delete), "a take doesn't"); + assert!(!keys.take(ctx, Key::Escape)); + }); + } +} diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 4868b22e4a..b0bf54e3b8 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -797,3 +797,120 @@ fn the_page_guards_read_hit_tests_split_calls_and_aliases() { let aliases: Vec<(String, String)> = [("hit", "corner_under"), ("picture_hit", "under")].map(|(a, b)| (a.to_owned(), b.to_owned())).to_vec(); assert_eq!(aliased_hit_tests(source), aliases); } + +/// egui's raw key state: a key pressed, held, let go or taken, and the key, text and clipboard +/// events themselves, however they are imported. +const RAW_KEY_READS: [&str; 17] = [ + "Event::Copy", + "Event::Cut", + "Event::Paste", + "Event::Text", + "Event::{", + "Event::*", + "Event as ", + "InputState::", + "key_pressed(", + "key_released(", + "key_down(", + "keys_down", + "num_presses(", + "consume_key(", + "consume_shortcut(", + "count_and_consume_key(", + "Event::Key", +]; + +/// Functions in the files the key guard scans ([`key_files`]) that read egui's keys themselves: +/// a window's or a field's own keys, which `PageKeys` would hide, and the pages' reads that +/// `PageKeys` can't make (chords that wait for the menu bar instead, Escape ending what the pages +/// hold, any key stopping a glide, the clipboard's hand-off to text fields): (file, function, why). +const NOT_PAGE_KEYS: [(&str, &str, &str); 17] = [ + ( + "app/clipboard.rs", + "clipboard_keys", + "the pages' clipboard: Copy, Cut and Paste (events and chords), which act only when `page_keys.over()` is false, \ + and the pastes it hands on to text fields while something is over the pages", + ), + ( + "app/keys.rs", + "select_all_keys", + "Select all, a chord that waits for a menu of the menu bar as the registered commands' do, and isn't called while something covers the pages", + ), + ("canvas/area.rs", "glide_interrupted", "any key stops the touchpad's glide"), + ("canvas/autoscroll.rs", "update_latched", "Escape ends the middle-button scroll, which holds the whole view"), + ("canvas/autoscroll.rs", "escape", "Escape ends the middle-button scroll, which holds the whole view"), + ("canvas/find.rs", "find_bar", "the Find field's own keys, while it has the focus"), + ( + "canvas/navigation.rs", + "view_chords", + "the view's chords, which wait for a menu of the menu bar as the registered commands' do, and for whatever covers the pages", + ), + ("canvas/page_input.rs", "cancel_on_escape", "Escape cancels a press the pages hold, which goes on under a dialog opened during it"), + ("tools/calc_order_ui.rs", "panel", "the Calculation order panel's own Alt+arrows"), + ("tools/comments/composer.rs", "submit_key", "the comment field's own Enter, while it has the focus (the Comments panel's too)"), + ("tools/field_table_ui.rs", "show", "the Text field table window's own Escape"), + ("tools/field_table_ui.rs", "column_dividers", "the Text field table window's own arrows"), + ("tools/fill_sign.rs", "signature_pad", "the signature dialog's own ⌘Z"), + ("tools/forms_ui.rs", "combo_editor", "the combo box's own Enter, while its field has the focus"), + ("tools/prepare/properties.rs", "body", "the field properties' own Enter, while one of their fields has the focus"), + ("tools/redact_ui.rs", "search_body", "the redaction search field's own Enter, while it has the focus"), + ("tools/sign_ui/dialog.rs", "sign_as", "the signing dialog's own Enter"), +]; + +/// The files whose key reads the key guard checks, relative to `src`: every file under `canvas/` +/// and `tools/` and the canvas's root, whether or not it is a page module yet, the app's own keys +/// and clipboard, and the Pages panel's thumbnails; not their tests, and not `PageKeys` itself. A +/// helper in another file that page code hands egui's input to isn't seen. +fn key_files() -> Vec { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = ["canvas.rs", "app/keys.rs", "app/clipboard.rs", "panels/pages.rs"].map(str::to_owned).to_vec(); + let mut dirs = vec![src.join("canvas"), src.join("tools")]; + while let Some(dir) = dirs.pop() { + for entry in std::fs::read_dir(dir).expect("source directory") { + let path = entry.expect("source entry").path(); + let stem = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + if stem == "tests" || stem.ends_with("_tests") { + continue; + } + if path.is_dir() { + dirs.push(path); + } else if path.extension().is_some_and(|ext| ext == "rs") { + let file = + path.strip_prefix(&src).expect("under src").components().map(|c| c.as_os_str().to_string_lossy()).collect::>().join("/"); + if file != "canvas/page_keys.rs" { + files.push(file); + } + } + } + } + files.sort(); + files +} + +/// Page code reads keys only through `PageKeys`. The pages are drawn before the dialogs and the +/// palette and read egui's keys first, so a raw read acts on what is selected or typed behind +/// whatever is over them: Delete under a dialog deleted the comment selected behind it, and the +/// Escape that closed one threw away the paragraph being typed. +#[test] +fn page_modules_read_keys_only_through_page_keys() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let files = key_files(); + let mut found = Vec::new(); + for file in &files { + let source = std::fs::read_to_string(src.join(file)).expect("source file").replace("\r\n", "\n"); + for (n, function, text) in code_lines(&source) { + for name in RAW_KEY_READS.iter().filter(|name| uses(&text, name)) { + found.push((file.as_str(), function.map(str::to_owned), n, (*name).to_owned())); + } + } + } + let problems = misuses_in( + found, + &NOT_PAGE_KEYS, + "reads egui's keys in page code. Read them through `PageKeys` (src/canvas/page_keys.rs) instead: it has none \ + while a dialog, the palette or a menu is over the pages, which read keys before those are drawn. Not the \ + pages' keys? Add the function to NOT_PAGE_KEYS in src/canvas/page_pointer/tests.rs, with why.", + "reads no keys of its own any more: remove it from NOT_PAGE_KEYS.", + ); + assert!(problems.is_empty(), "{}", problems.join("\n")); +} diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index dab411bdf0..87bda69e86 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -763,7 +763,7 @@ fn keyboard_find_step_is_explicit_and_a_late_text_layer_cannot_undo_it() { ctx.input_mut(|i| { i.events.push(egui::Event::Key { key: egui::Key::G, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); }); - shortcuts(&mut v, &ctx, Default::default()); + shortcuts(&mut v, &ctx, Default::default(), true, Default::default()); assert_eq!(v.current, 2, "the keyboard step chose the next match"); assert_eq!(v.find.as_ref().unwrap().auto_from, None, "the keyboard step is the user's choice"); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index ac52586062..1ad3e0f5ed 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -113,7 +113,7 @@ impl PdfCraftApp { /// Last-resort guard (AGENTS.md §4): a command that panics is reported and the app, with its /// open documents, keeps running. Edits are applied to a copy, so the document is unchanged. pub fn execute(&mut self, id: &str) -> bool { - match pdfcraft_engine::guard(|| self.execute_unguarded(id)) { + let done = match pdfcraft_engine::guard(|| self.execute_unguarded(id)) { Ok(done) => done, Err(m) => { // A command stopped part way through closing or replacing a tab must not leave @@ -122,7 +122,9 @@ impl PdfCraftApp { self.notify_fmt("That didn't work: an internal error stopped it ({m}). Your documents are unchanged.", &[("m", m.as_str())]); true } - } + }; + crate::dialogs::discard_inactive_password_drafts(self); + done } /// Why the command can't run right now, for the user. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0f93ae8e2d..b389ca431d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,11 +26,22 @@ pub(super) mod word_count_ui; #[cfg(test)] mod tests; -pub(crate) use prompts::{PromptButton, save_prompt_key}; +pub(crate) use prompts::{PromptButton, save_prompt_key, shorten_middle}; use prompts::{full_screen_prompt, link_prompt, password, remote_link_prompt, save_prompt, unlock}; const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; +/// Drop (and zeroize) password drafts as soon as their owning dialog is inactive. +/// Commands and control requests also call this: close/reopen can happen between frames. +pub(crate) fn discard_inactive_password_drafts(app: &mut PdfCraftApp) { + if app.dialog != Some(Dialog::Protect) { + app.protect_draft = Default::default(); + } + if app.dialog != Some(Dialog::Sign) { + app.sign_draft = None; + } +} + /// What the dialog's widgets asked for this frame; acted on after the modal is drawn. #[derive(Default)] struct Pending { @@ -106,6 +117,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { if app.dialog != Some(Dialog::FieldTable) { app.field_table_draft = None; } + discard_inactive_password_drafts(app); // A movable window, not this modal: the page under it shows the table as it changes. if app.dialog == Some(Dialog::FieldTable) { crate::field_table_ui::show(app, ctx); @@ -2214,6 +2226,7 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: } else { app.dialog = Some(req.next.unwrap_or(dialog)); } + discard_inactive_password_drafts(app); if let Some(cmd) = req.link_command { app.execute(cmd); } @@ -2282,3 +2295,134 @@ fn draft_changes(app: &PdfCraftApp) -> Option> { fn yes(b: bool) -> String { tl!(if b { "Yes" } else { "No" }).to_string() } + +#[cfg(test)] +mod password_draft_tests { + use super::*; + use egui_kittest::Harness; + + fn seed_password_drafts(app: &mut PdfCraftApp) { + app.start_signing(0, None, None, None); + let sign = app.sign_draft.as_mut().unwrap(); + sign.step = crate::sign_ui::SignStep::Choose; + sign.password = "sign password".into(); + sign.new_id.password = "new ID password".into(); + sign.new_id.confirm = "new ID password".into(); + sign.new_id.file_password = "import password".into(); + app.protect_draft.password = "view password".into(); + app.protect_draft.confirm = "view password".into(); + app.protect_draft.protection.open_password = Some("open password".into()); + app.protect_draft.protection.permissions_password = Some("owner password".into()); + } + + fn assert_protect_cleared(app: &PdfCraftApp) { + assert_eq!(app.protect_draft, crate::protect::ProtectDraft::default()); + } + + #[test] + fn inactive_password_drafts_are_cleared_before_early_returns() { + // None and FieldTable return before the ordinary modal-close path; About replaces it. + for dialog in [None, Some(Dialog::FieldTable), Some(Dialog::About)] { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + seed_password_drafts(&mut app); + app.dialog = dialog; + app + }); + h.run_steps(2); + assert_protect_cleared(h.state()); + assert!(h.state().sign_draft.is_none()); + } + } + + #[test] + fn rendering_preserves_only_the_active_password_draft() { + for dialog in [Dialog::Protect, Dialog::Sign] { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + seed_password_drafts(&mut app); + app.dialog = Some(dialog); + app + }); + h.run_steps(2); + if dialog == Dialog::Protect { + assert_eq!(h.state().protect_draft.password, "view password"); + assert_eq!(h.state().protect_draft.confirm, "view password"); + assert!(h.state().sign_draft.is_none()); + } else { + assert_protect_cleared(h.state()); + let sign = h.state().sign_draft.as_ref().unwrap(); + assert_eq!(sign.password, "sign password"); + assert_eq!(sign.new_id.password, "new ID password"); + assert_eq!(sign.new_id.confirm, "new ID password"); + assert_eq!(sign.new_id.file_password, "import password"); + } + } + } + + #[test] + fn control_close_and_reopen_clears_passwords_without_a_render_between_requests() { + let mut app = PdfCraftApp::new(); + // The control queue can handle several ui.set requests before the next rendered frame. + // Repeat both close and replacement flows so stale drafts cannot return on later opens. + for _ in 0..2 { + for replacement in ["none", "about", "sign", "certify"] { + app.set_option("dialog", "protect").unwrap(); + app.protect_draft.password = "old password".into(); + app.protect_draft.confirm = "old password".into(); + app.set_option("dialog", replacement).unwrap(); + assert_protect_cleared(&app); + app.set_option("dialog", "protect").unwrap(); + assert_protect_cleared(&app); + assert!(app.sign_draft.is_none()); + } + for replacement in ["none", "about", "protect"] { + seed_password_drafts(&mut app); + app.set_option("dialog", replacement).unwrap(); + assert!(app.sign_draft.is_none(), "inactive Sign draft retained after {replacement}"); + app.set_option("dialog", "sign").unwrap(); + let sign = app.sign_draft.as_ref().unwrap(); + assert!(sign.password.is_empty()); + assert_eq!(sign.new_id, crate::sign_ui::NewIdDraft::default()); + } + } + } + + #[test] + fn selecting_the_active_protect_dialog_keeps_its_password() { + let mut app = PdfCraftApp::new(); + app.set_option("dialog", "protect").unwrap(); + app.protect_draft.password = "current password".into(); + app.protect_draft.confirm = "current password".into(); + app.set_option("dialog", "protect").unwrap(); + assert_eq!(app.protect_draft.password, "current password"); + assert_eq!(app.protect_draft.confirm, "current password"); + } + + #[test] + fn commands_discard_replaced_password_drafts_before_the_next_control_request() { + let mut app = PdfCraftApp::new(); + for _ in 0..2 { + seed_password_drafts(&mut app); + app.dialog = Some(Dialog::Protect); + assert!(app.execute("help.about")); + assert_eq!(app.dialog, Some(Dialog::About)); + assert_protect_cleared(&app); + assert!(app.sign_draft.is_none()); + app.set_option("dialog", "protect").unwrap(); + assert_protect_cleared(&app); + + seed_password_drafts(&mut app); + assert!(app.execute("help.shortcuts")); + assert_eq!(app.dialog, Some(Dialog::Shortcuts)); + assert_protect_cleared(&app); + assert!(app.sign_draft.is_none()); + app.set_option("dialog", "sign").unwrap(); + let sign = app.sign_draft.as_ref().unwrap(); + assert!(sign.password.is_empty()); + assert_eq!(sign.new_id, crate::sign_ui::NewIdDraft::default()); + } + } +} diff --git a/crates/ui-egui/src/dialogs/prompts.rs b/crates/ui-egui/src/dialogs/prompts.rs index 91e069bca3..b1aa162790 100644 --- a/crates/ui-egui/src/dialogs/prompts.rs +++ b/crates/ui-egui/src/dialogs/prompts.rs @@ -130,7 +130,7 @@ pub(super) fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { /// `name` cut to at most `max` characters by replacing its middle with "…", keeping the start and /// the end, where the extension and version suffixes sit. Counts `char`s, so it never splits one. -fn shorten_middle(name: &str, max: usize) -> String { +pub(crate) fn shorten_middle(name: &str, max: usize) -> String { let count = name.chars().count(); if count <= max { return name.to_string(); @@ -351,7 +351,7 @@ pub(super) fn password(app: &mut PdfCraftApp, ctx: &egui::Context) { let message = crate::i18n::fmt(tl!("“{name}” is protected. Enter a password to open it."), &[("name", &prompt.name)]); match password_modal(ctx, "password", tl!("Password required"), message, &mut prompt.input, prompt.error.as_deref(), tl!("Open")) { Some(PasswordAnswer::Submit) => { - let pw = prompt.input.clone(); + let pw = std::mem::take(&mut prompt.input); app.submit_password(Some(pw)); } Some(PasswordAnswer::Cancel) => app.submit_password(None), @@ -366,7 +366,7 @@ pub(super) fn unlock(app: &mut PdfCraftApp, ctx: &egui::Context) { crate::i18n::fmt(tl!("“{name}” has security restrictions. Enter its permissions password to lift them."), &[("name", &prompt.name)]); match password_modal(ctx, "unlock", tl!("Permissions password"), message, &mut prompt.input, prompt.error.as_deref(), tl!("Unlock")) { Some(PasswordAnswer::Submit) => { - let pw = prompt.input.clone(); + let pw = std::mem::take(&mut prompt.input); app.submit_unlock(Some(pw)); } Some(PasswordAnswer::Cancel) => app.submit_unlock(None), diff --git a/crates/ui-egui/src/dialogs/protect.rs b/crates/ui-egui/src/dialogs/protect.rs index ffc8b53f8e..cc79f3c82c 100644 --- a/crates/ui-egui/src/dialogs/protect.rs +++ b/crates/ui-egui/src/dialogs/protect.rs @@ -25,6 +25,20 @@ impl Default for ProtectDraft { } } +impl Drop for ProtectDraft { + fn drop(&mut self) { + use zeroize::Zeroize as _; + self.password.zeroize(); + self.confirm.zeroize(); + if let Some(pw) = &mut self.protection.open_password { + pw.zeroize(); + } + if let Some(pw) = &mut self.protection.permissions_password { + pw.zeroize(); + } + } +} + impl ProtectDraft { /// Why Apply is disabled, if it is. pub fn problem(&self) -> Option<&'static str> { @@ -251,4 +265,40 @@ mod tests { assert_eq!(strength("Abcdefg1").0, "Medium"); assert_eq!(strength("Correct-Horse-9").0, "Strong"); } + + #[test] + fn closing_the_protect_dialog_clears_the_password_draft() { + use egui_kittest::{Harness, kittest::Queryable}; + + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.dialog = Some(crate::Dialog::Protect); + app.protect_draft.password = "TopSecret123!".into(); + app.protect_draft.confirm = "TopSecret123!".into(); + app + }); + h.run_steps(2); + + // Clicking Cancel closes Dialog::Protect and clears the draft passwords. + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert!(h.state().protect_draft.password.is_empty(), "password must not linger after cancelling Dialog::Protect"); + assert!(h.state().protect_draft.confirm.is_empty(), "confirm must not linger after cancelling Dialog::Protect"); + + // Pressing Escape on Dialog::Protect also clears the draft passwords. + { + let app = h.state_mut(); + app.dialog = Some(crate::Dialog::Protect); + app.protect_draft.password = "AnotherSecret!".into(); + app.protect_draft.confirm = "AnotherSecret!".into(); + } + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert!(h.state().protect_draft.password.is_empty(), "password must not linger after closing Dialog::Protect"); + assert!(h.state().protect_draft.confirm.is_empty(), "confirm must not linger after closing Dialog::Protect"); + } } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 0af015367d..5b5a025b79 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -30,6 +30,16 @@ fn new_text_typed(v: &crate::DocView) -> bool { v.content.draft.as_ref().is_some_and(|d| d.index.is_none() && !d.text.trim().is_empty()) } +/// Clear an unfinished page-anchored drawing or composer when a page edit shifts or removes `page`. +fn clear_shifted_page_comment_state(view: &mut crate::DocView, shifted_from: usize) { + if matches!(view.comments.gesture, Some(crate::comments::Gesture::Draw { page, .. }) if page >= shifted_from) { + view.comments.gesture = None; + } + if view.comments.composer.as_ref().is_some_and(|c| !matches!(c.kind, crate::comments::ComposerKind::Edit(_)) && c.page >= shifted_from) { + view.comments.composer = None; + } +} + /// Keeps the pages (or comment) the user acted on selected, where they now are. `annots_before` /// is the page's annotations before a delete or a paste of comments (empty for other edits). fn select_after_edit( @@ -41,17 +51,27 @@ fn select_after_edit( ) { match edit { Edit::MovePages { pages, to } => { + clear_shifted_page_comment_state(view, 0); let n = pages.iter().collect::>().len(); let to = (*to).min(info.pages.len() - n); view.select_pages(&(to..to + n).collect::>()); } Edit::DuplicatePages { pages, at: Some(at) } => { + clear_shifted_page_comment_state(view, *at); let n = pages.iter().collect::>().len(); let at = (*at).min(info.pages.len().saturating_sub(n)); view.select_pages(&(at..at + n).collect::>()); } - Edit::InsertBlankPage { at, .. } => view.select_pages(&[(*at).min(info.pages.len() - 1)]), - Edit::DeletePages { .. } => view.select_pages(&[]), + Edit::InsertBlankPage { at, .. } => { + clear_shifted_page_comment_state(view, *at); + view.select_pages(&[(*at).min(info.pages.len() - 1)]); + } + Edit::DeletePages { pages } => { + if let Some(&first) = pages.iter().min() { + clear_shifted_page_comment_state(view, first); + } + view.select_pages(&[]); + } // A stroke drawn with the pen stays unselected, so the selection box and // author popup don't sit over the next stroke (#429). Edit::AddAnnotation(a) @@ -78,7 +98,6 @@ fn select_after_edit( view.comments.open_editor(a.page, n, String::new()); } } - Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)), // A group moved together stays selected (its positions don't change), so it // can be moved or copied again; anything else in a batch clears the selection. Edit::Batch { edits, .. } if edits.iter().all(|e| matches!(e, Edit::MoveAnnotation { .. })) => {} @@ -209,25 +228,15 @@ impl PdfCraftApp { .map(|before| form_edit_pages(&before, &doc.form)); let one_page = comment_page(&edit).or_else(|| content_page(&edit)); if signatures_before.is_some_and(|before| crate::canvas::signature_states_changed(&before, &doc.signatures)) { - // A signature shows its state on its own page, which an edit elsewhere can - // change (a certification forbidding it, say): every page redraws. An edit that - // moves no page (a form edit, or one to a page's comments or content) keeps the - // values typed, in it and before it, showing until their pages redraw. - let waiting = (form_edit.is_some() || one_page.is_some()).then(|| std::mem::take(&mut view.forms.unbaked)); - view.document_changed(info); - if let Some(waiting) = waiting { - view.forms.unbaked = waiting; - } - if let Some((_, unbaked)) = form_edit { - view.forms.unbaked.extend(unbaked); - } + signatures_changed_after_edit(view, &edit, info, &annots_before, form_edit.map(|(_, u)| u), one_page); } else if let Some(page) = one_page { - one_page_changed(view, &edit, page); + one_page_changed(view, &edit, page, &annots_before, info); } else if let Some((changed_pages, unbaked)) = form_edit { view.forms.unbaked.extend(unbaked); for page in changed_pages { view.page_changed(page); } + view.comments.document_changed(info); } else { view.document_changed(info); } @@ -1111,13 +1120,51 @@ fn content_page(edit: &Edit) -> Option { } } +/// A signature shows its state on its own page, which an edit elsewhere can change (a +/// certification forbidding it, say): every page redraws. An edit that moves no page (a form edit, +/// or one to a page's comments or content) keeps the values typed, in it and before it, showing +/// until their pages redraw. +fn signatures_changed_after_edit( + view: &mut crate::DocView, + edit: &Edit, + info: &pdfcraft_render::DocInfo, + annots_before: &[pdfcraft_render::Annotation], + form_unbaked: Option>, + one_page: Option, +) { + let waiting = (form_unbaked.is_some() || one_page.is_some()).then(|| std::mem::take(&mut view.forms.unbaked)); + if let Edit::DeleteAnnotation { page, index } = edit { + // Remap surviving comments before generic slot pruning loses their state. + view.comments.annotation_deleted(annots_before, info, (*page, *index)); + } + view.document_changed(info); + if let Some(waiting) = waiting { + view.forms.unbaked = waiting; + } + if let Some(unbaked) = form_unbaked { + view.forms.unbaked.extend(unbaked); + } +} + /// A comment or content edit changes one page, `page`: keep every other raster. Opening, closing /// or moving a pop-up changes none: the window is drawn over the page, and a /Popup annotation /// paints nothing in the raster (ISO 32000-2 §12.5.6.14). -fn one_page_changed(view: &mut crate::DocView, edit: &Edit, page: usize) { +fn one_page_changed( + view: &mut crate::DocView, + edit: &Edit, + page: usize, + annots_before: &[pdfcraft_render::Annotation], + info: &pdfcraft_render::DocInfo, +) { if !matches!(edit, Edit::SetAnnotationPopup { .. }) { view.page_changed(page); } + if let Edit::DeleteAnnotation { page, index } = edit { + // Remap surviving comments before generic slot pruning loses their state. + view.comments.annotation_deleted(annots_before, info, (*page, *index)); + } else { + view.comments.document_changed(info); + } } fn comment_page(edit: &Edit) -> Option { diff --git a/crates/ui-egui/src/editing/tests.rs b/crates/ui-egui/src/editing/tests.rs index b53ac49305..1f205ae759 100644 --- a/crates/ui-egui/src/editing/tests.rs +++ b/crates/ui-egui/src/editing/tests.rs @@ -375,6 +375,40 @@ fn save_commits_the_open_text_box() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn moving_pages_cancels_an_in_progress_drawing() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>", + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, pdf).unwrap(); + let drawing = + || Some(crate::comments::Gesture::Draw { page: 0, tool: crate::comments::CommentTool::Ink, points: vec![[10.0, 10.0], [30.0, 20.0]] }); + app.views[0].comments.gesture = drawing(); + assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + assert!(app.views[0].comments.gesture.is_some(), "rotating the page keeps the drawing on it"); + app.views[0].comments.gesture = drawing(); + assert!(app.apply_edit(pdfcraft_engine::Edit::MovePages { pages: vec![1], to: 0 })); + assert!(app.views[0].comments.gesture.is_none(), "the drawing's page index would now be a different page"); +} + #[test] fn save_asks_where_to_keep_an_email_attachment_and_leaves_the_temporary_copy_alone() { let dir = staging_dir("mail-temp"); diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index 34b3480b40..4d4539a465 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -23,6 +23,35 @@ pub(crate) struct Hotbar { search: String, #[serde(skip)] picker_open: bool, + /// The pass the Hotbar tools window was last drawn on. + #[serde(skip)] + picker_shown: Option, +} + +impl Hotbar { + /// The Hotbar tools window is over the pages: open, and drawn on the last pass. Read mode, full + /// screen, Organize and a document that hides the toolbars don't draw the hotbar, and a window + /// they hide holds no keys. + pub(crate) fn picker_over_pages(&self, ctx: &egui::Context) -> bool { + self.picker_open && self.picker_shown.is_some_and(|pass| pass.saturating_add(1) >= ctx.cumulative_pass_nr()) + } + + /// Closes the Hotbar tools window when the hotbar is `hidden` on this pass (Read mode or full + /// screen, which a native menu's click may have turned on just now), or the last pass didn't + /// draw it (Organize, or a document that hides the toolbars). Left open, it would hold the keys of the pass that hides it, and come + /// back over the pages on the pass that shows the hotbar again, after that pass's page keys + /// had been read without it. + pub(crate) fn close_hidden_picker(&mut self, ctx: &egui::Context, hidden: bool) { + if self.picker_open && (hidden || !self.picker_over_pages(ctx)) { + self.close_picker(); + } + } + + /// Closes the Hotbar tools window. + pub(crate) fn close_picker(&mut self) { + self.picker_open = false; + self.picker_shown = None; + } } impl Default for Hotbar { @@ -38,6 +67,7 @@ impl Default for Hotbar { placed: false, search: String::new(), picker_open: false, + picker_shown: None, } } } @@ -871,6 +901,7 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui data.insert_temp(egui::Id::unique("custom-hotbar"), shown.response.rect.size()); }); bar.picker_open = picker_open; + bar.picker_shown = picker_open.then(|| ctx.cumulative_pass_nr()); app.hotbar = bar; if let Some(id) = run { app.execute(id); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0b3243a097..044b8e8225 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -59,7 +59,10 @@ mod home; mod hotbar; mod panels; pub mod panes; -pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Spooler, Which as PrintWhich, system_printers}; +pub use print_ui::{ + Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSetup, PrinterSource, Spooler, TwoSidedSource, Which as PrintWhich, system_printers, + system_two_sided, +}; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; pub mod theme; @@ -103,6 +106,7 @@ use canvas::crop; use canvas::fling; use canvas::image_pick; use canvas::page_cursor; +use canvas::page_keys; use canvas::page_pointer; use canvas::page_router; pub use canvas::scroll_speed; @@ -279,6 +283,7 @@ struct PendingOpen { #[cfg(not(target_arch = "wasm32"))] struct DeferredOpen { path: String, + after_open: Option, then: Option<&'static str>, display_override: Option<(canvas::PageLayout, bool)>, } @@ -287,6 +292,15 @@ struct DeferredOpen { /// used instead. pub type HtmlClipboard = Box bool>; +/// A named destination being looked up off the UI thread: reading a large document's name tree +/// takes a noticeable moment. +#[cfg(not(target_arch = "wasm32"))] +struct PendingDestination { + doc: DocId, + name: String, + receiver: std::sync::mpsc::Receiver>, +} + pub struct PdfCraftApp { /// Split view (`panes`); which pane shows each tab is its view's. pub panes: panes::Panes, @@ -422,6 +436,9 @@ pub struct PdfCraftApp { pub options_source: Option, /// What print jobs go to: the system spooler unless a test stands in ([`Spooler`]). pub spooler: Option, + /// Reads the picked printer's two-sided support in the background (the desktop app sets it; + /// see [`TwoSidedSource`]). Without one the dialog goes by the printer list alone. + pub two_sided_source: Option, // A browser has no background print work: the type is empty there. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) print_tasks: print_ui::PrintTasks, @@ -479,6 +496,8 @@ pub struct PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] password_retry: Option, #[cfg(not(target_arch = "wasm32"))] + pending_destinations: Vec, + #[cfg(not(target_arch = "wasm32"))] next_open_token: u64, /// Explicit CLI layout waits for flagged launch opens, never targeting restored tabs. #[cfg(not(target_arch = "wasm32"))] @@ -729,8 +748,14 @@ pub struct PdfCraftApp { deferred_commands: Vec<(&'static str, Option)>, /// The native menu bar (macOS), while it is installed; the in-window bar shows otherwise. pub(crate) native_menu: Option, - /// Input a menu click stands for (⌘C, Delete…), added to the next frame's input in `logic`. + /// Input a menu click stands for (⌘C, Delete…), waiting to join a pass's input (see + /// `release_menu_input`). pub(crate) menu_input: Vec, + /// Edit ▸ Paste clicks waiting to ask the platform for the clipboard (see + /// `release_menu_input`). + pub(crate) menu_pastes: usize, + /// This frame's keys as the pages see them: none while something is over them. + pub(crate) page_keys: page_keys::PageKeys, /// The dialog seen at the last check, and a counter bumped whenever it changes (see /// [`Self::dialog_epoch`]). dialog_seen: Option, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 066a914e0d..5efbba309d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -552,7 +552,7 @@ impl PdfCraftApp { } "edit.copy" => self.menu_input.push(egui::Event::Copy), CUT => self.menu_input.push(egui::Event::Cut), - PASTE => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), + PASTE => self.menu_pastes = self.menu_pastes.saturating_add(1), SELECT_ALL => self.menu_input.extend(key(egui::Key::A, egui::Modifiers::COMMAND)), DELETE => self.menu_input.extend(key(egui::Key::Delete, egui::Modifiers::NONE)), PANEL => self.choose_right_panel(panel(command)), diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index 102da43db8..62614b3173 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -611,6 +611,11 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti let menu_id = ui.scope_id().with(("card-menu", key)); if open_menu { egui::Popup::open_id(ui.ctx(), menu_id); + } else if more_rect.is_none() { + // The button is drawn on the selected card alone. Without it (Undo deselected the card + // with its menu open), egui would keep the menu open with nowhere to show it, and an + // open popup holds every page key (`PageKeys`). + egui::Popup::close_id(ui.ctx(), menu_id); } // Hang the menu from the "…" button (a pointer anchor would follow the mouse to the items). let anchor = more_rect.map_or(egui::PopupAnchor::PointerFixed, egui::PopupAnchor::ParentRect); diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 1d16331eb9..b53fb12575 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -87,7 +87,17 @@ pub(super) fn thumbnail_size(ui: &mut egui::Ui, t: &Tokens, view: &mut crate::Do /// (the selection page commands and Print act on) without moving the document. Right-click /// offers Extract, Cut, Copy and Paste on the selection, or on the page clicked; dragging /// moves the selection, or the page grabbed, to the gap it is dropped on. -pub(super) fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocView, modal: bool, editable: bool, nav: &mut Option