diff --git a/CLAUDE.md b/CLAUDE.md index ef8a730ede..5bf9403c94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `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(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. +- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. - **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. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. diff --git a/Cargo.lock b/Cargo.lock index c4f57c0260..62a9970673 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4334,9 +4334,11 @@ name = "pdfcraft-create" version = "0.15.0" dependencies = [ "encoding_rs", + "hayro-ccitt", "hayro-syntax", "image", "pdfcraft-cos", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-model", "pdfcraft-xfa", @@ -4369,6 +4371,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "sha2 0.11.1", "thiserror 2.0.21", "unicode-bidi", ] @@ -4379,6 +4382,7 @@ version = "0.15.0" dependencies = [ "chrono", "craft-docpath", + "getrandom 0.4.3", "icu_properties", "idna", "image", @@ -4425,7 +4429,9 @@ dependencies = [ name = "pdfcraft-filters" version = "0.15.0" dependencies = [ + "fax", "flate2", + "hayro-ccitt", "proptest", "thiserror 2.0.21", "weezl 0.2.1", @@ -4508,6 +4514,7 @@ dependencies = [ "pdfcraft-filters", "pdfcraft-model", "pdfcraft-render", + "proptest", "thiserror 2.0.21", "zune-jpeg", ] @@ -4568,6 +4575,7 @@ dependencies = [ "pdfcraft-content", "pdfcraft-cos", "pdfcraft-edit", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", diff --git a/Cargo.toml b/Cargo.toml index 4938963369..d374218fd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,11 @@ hayro-syntax = "0.7.2" lopdf = "0.45" flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] } weezl = "0.2" +# CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff. +fax = "0.2.7" +# CCITT fax decoding (pdfcraft-filters' decode_ccitt), also the independent decoder in its +# encoder's round-trip tests; already in the tree through hayro. +hayro-ccitt = "0.3" # foundation thiserror = "2" diff --git a/ROADMAP.md b/ROADMAP.md index f5de8044e1..6d261cc1f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # PdfCraft roadmap +> **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) + **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) @@ -95,8 +97,8 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i | | Value | Kind | |---|---|---| -| **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` | -| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | +| **Feature breadth** | **≈ 67%** tier-weighted (57.0% unweighted: 423 shipped + 75 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` | +| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.3% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, full Acrobat Pro | **≈ 50%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) | | **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) | @@ -151,7 +153,7 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef | H Digital signatures | 62.2% | 45% | 40–70 | | I Scan and OCR | 23.1% | 15% | 40–75 | | J Create and export | 39.7% | 25% | 55–100 | -| K Optimize and standards | 34.5% | 20% | 65–115 | +| K Optimize and standards | 35.3% | 20% | 65–115 | | L Print | 39.7% | 35% | 30–55 | | M Accessibility | 63.1% | 30% | 40–70 | | N Automation, preferences, misc | 30.0% | 35% | 25–45 | @@ -201,10 +203,15 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **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-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-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. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. +- **2026-10-24 (M11, more black-and-white images):** The optimizer re-stores Group 3 fax images as Group 4 when that is smaller, instead of leaving any CCITT image it doesn't resample as it is. Black-and-white images stored as eight-bit RGB (as some phone and scanner apps write them) become one-bit grey, and indexed images that use only their first two palette entries become one bit with their palette, so Monochrome Images compresses and downsamples them too. +- **2026-10-24 (M11, monochrome downsampling):** The PDF Optimizer and Reduce File Size average black-and-white images above 450 ppi down to 300 ppi (Monochrome Images ▸ Downsample to), Group 4 scans included, so a 600 ppi text page goes from 79 KB to 40 KB. CCITT decoding moved into `pdfcraft-filters` (`decode_ccitt`), shared by Export ▸ Images and the optimizer through `Document::decoded_image`. +- **2026-10-24 (M11, black-and-white pictures):** A scan saved as a 1-bit PNG became an eight-bit grey image when made into a PDF, larger than the PNG; Create PDF now stores pictures of only black and white at one bit (Group 4 when smaller), the optimizer packs eight-bit black-and-white grey images to one bit unless the grey settings make them smaller, as they can a dithered scan, soft mask counted, and Export ▸ Images reads CCITT fax images. +- **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. - **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. - **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged. @@ -220,6 +227,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data. 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 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. @@ -300,6 +308,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged. - **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged. - **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged. +- **2026-10-16 (M7, Add Background):** A background added behind a page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) was hidden completely, so the tool looked broken. The dialog now detects such pages and offers to replace the backdrop (on by default): the one fill becomes `n` in a per-page copy of its stream, and Remove background turns the fill back on, after edits and redactions too, unless the operators before it changed or it would no longer be painted first; then it says which pages keep it off. The copy's tag holds no reference to the original stream, which had kept text a redaction or Remove Hidden Information removed in the saved file. Scanned pages are warned about, not touched. New `doc_backdrops` tool; `doc_background` takes `replace_backdrop`. The default colour is a visible light yellow and the preview shows text over it. Acrobat's own behaviour on such pages is unobserved. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. - **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. @@ -449,6 +458,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged. - **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged. - **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged. - **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged. @@ -748,3 +758,7 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged. - **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged. + +## Pages sidebar keyboard refresh (2026-10-30) + +PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape after the UI module split. The selection does not navigate. The focused regression is in `crates/ui-egui/tests/it/view.rs`; current-head hosted validation is pending. Feature counts and estimates are unchanged. diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index e9e0359ce0..7bd0894c66 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -33,11 +33,13 @@ pub use info::{Props, Summary, is_comment_subtype, props, set_info, summaries}; #[cfg(test)] use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref}; use lookup::{annots, page_ref}; -pub use lookup::{has_visible_fill_sign, is_fill_sign, isolate_annotation, page_refs, picture_rotation, set_annots, signature_image}; +pub use lookup::{ + has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image, +}; pub(crate) use modify::n; pub use modify::{ - add_reply, add_text_replacement, delete_annotation, erase_ink, fillable, goes_with, move_annotation, set_contents, set_fill, set_font_size, - set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, + add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, + set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, }; pub use types::{ AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index c81ec78ef4..c7a0fd9831 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -226,16 +226,33 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result Result { + isolate_annotations(doc, page, &[index]) +} + +/// [`isolate_annotation`] for several annotations of one page: those of them with a normal +/// appearance stay, in their order on the page. `false`, with `doc` unchanged, when none has one. +/// An index that isn't one of the page's annotations is an error even when the others are, and +/// leaves `doc` unchanged too. +pub fn isolate_annotations(doc: &mut Document, page: usize, indices: &[usize]) -> Result { let p = page_ref(doc, page)?; - let entry = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; - let has_normal = { - let obj = doc.resolve(&entry); - obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) - }; - if !has_normal { + let list = annots(doc, p); + let mut kept = Vec::new(); + for (index, entry) in list.iter().enumerate() { + if !indices.contains(&index) { + continue; + } + let obj = doc.resolve(entry); + if obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) { + kept.push(entry.clone()); + } + } + if let Some(&index) = indices.iter().find(|&&i| i >= list.len()) { + return Err(AnnotError::NoSuchAnnotation { page, index }); + } + if kept.is_empty() { return Ok(false); } - set_annots(doc, p, vec![entry])?; + set_annots(doc, p, kept)?; doc.update_dict(p, |d| { d.remove(b"Contents"); })?; diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index fc81348075..2e3ae0b7d0 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -70,20 +70,53 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) { } /// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does. +/// Refused while it, or a reply that would go with it, is locked. pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { - let p = page_ref(doc, page)?; - let list = annots(doc, p); - let target = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; + deletable(doc, page, index)?; + lift_annotation(doc, page, index) +} + +/// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without +/// changing anything: what a Delete command asks before it is offered. It is refused while the +/// annotation is locked, or a reply that would go with it is. +pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotError> { + let list = annots(doc, page_ref(doc, page)?); + let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; if let Some(r) = target.as_ref() { unlocked(doc, r)?; } - // An inline comment has no reference to collect; the pop-up it names still goes. - let seed = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))); - let doomed = with_replies_and_popups(doc, &list, seed); - let kept: Vec = list.into_iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e).collect(); + let doomed = with_replies_and_popups(doc, &list, seed(target)); + // A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it + // answers either. Pop-ups follow their comment. + for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) { + let obj = doc.resolve(e); + if let Some(d) = obj.as_dict().filter(|d| d.name(b"Subtype") != Some(b"Popup".as_slice())) + && annotation_flags(doc, d) & FLAG_LOCKED != 0 + { + return Err(AnnotError::Invalid("a reply to it is locked".into())); + } + } + Ok(()) +} + +/// Take the annotation at `(page, index)` out of its page with its pop-up and replies, as +/// [`delete_annotation`] does but whatever their locks: for drawing the page beneath it on a +/// copy of the document (a stamp or signature being dragged), which changes nothing. +pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { + let p = page_ref(doc, page)?; + let list = annots(doc, p); + let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let doomed = with_replies_and_popups(doc, &list, seed(target)); + let kept: Vec = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect(); Ok(set_annots(doc, p, kept)?) } +/// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline +/// comment (which has none to collect) the pop-up it names, which still goes. +fn seed(target: &Object) -> Option { + target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))) +} + /// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is /// one of them, or it is written inline (a file can, though the standard asks for references) and /// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index eb8d1a0f15..a3dc14e262 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -229,6 +229,34 @@ fn delete_takes_popup_and_reply_chain() { assert_eq!(delete_annotation(&mut doc, 0, 9), Err(AnnotError::NoSuchAnnotation { page: 0, index: 9 })); } +/// A locked reply can't be deleted, so it doesn't go with what it answers either: deleting the +/// reply above it, or the comment, is refused and changes nothing; once it is unlocked they go. +#[test] +fn a_locked_reply_keeps_what_it_answers() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [100.0, 100.0], icon: NoteIcon::Comment }), &meta("n")).unwrap(); + let reply = add_reply(&mut doc, 0, note, "first", "Bob", &meta("r1")).unwrap(); + let answer = add_reply(&mut doc, 0, reply, "locked answer", "Ada", &meta("r2")).unwrap(); + set_locked(&mut doc, 0, answer, true).unwrap(); + let before = list(&doc, 0).len(); + assert_eq!(delete_annotation(&mut doc, 0, reply), Err(AnnotError::Invalid("a reply to it is locked".into()))); + assert_eq!(delete_annotation(&mut doc, 0, note), Err(AnnotError::Invalid("a reply to it is locked".into()))); + assert_eq!(list(&doc, 0).len(), before, "nothing went"); + // Asked first, read-only, as a Delete command does: the same answer, and the answer's own + // lock is its own. + assert_eq!(deletable(&doc, 0, note), Err(AnnotError::Invalid("a reply to it is locked".into()))); + assert_eq!(deletable(&doc, 0, answer), Err(AnnotError::Invalid("the comment is locked".into()))); + // Lifted on a copy, to draw the page beneath, the locks don't count. + let mut copy = doc.clone(); + lift_annotation(&mut copy, 0, note).unwrap(); + assert!(list(©, 0).len() < before, "the thread is lifted off the copy"); + set_locked(&mut doc, 0, answer, false).unwrap(); + assert_eq!(deletable(&doc, 0, reply), Ok(())); + delete_annotation(&mut doc, 0, reply).unwrap(); + let left: Vec = list(&doc, 0).iter().map(|d| text(d, b"Contents")).collect(); + assert!(!left.iter().any(|t| t == "first" || t == "locked answer"), "the reply and its answer went: {left:?}"); +} + /// A reply or pop-up written inline in `/Annots` (a file can, though the standard asks for /// references) goes with the comment it points at, and so does a pop-up an inline reply or an /// inline comment names. An unrelated inline comment stays until it is deleted itself. diff --git a/crates/automation/README.md b/crates/automation/README.md index d17084aa30..6040684eb5 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -181,6 +181,12 @@ working document unchanged. `content_list` or `object_list`. It removes only that item in a read-only preview and is mutually exclusive with `hide_text_block`. +When the renderer reports a warning for the page, `page_render` and `render_preview` return a +JSON part after the PNG, `{ "page": 1, "warnings": [...] }`, with the sentences +`pdfcraft-cli render` prints: "content past the page's safety budget was skipped" and "a colour +that could not be computed was painted in a fallback colour". A page without warnings returns +the PNG alone. + ## Existing text and document fonts @@ -229,3 +235,10 @@ behavior. Back/Forward returns to page tops, does not restore zoom, does not switch documents, and is not saved across restarts. Standalone engine/MCP sessions have no viewport, so they do not have a separate navigation-history tool; use the opt-in UI control channel to navigate the displayed document. + +## Fill & Sign text appearance + +`fill_sign_add` accepts `font_size` (4–300 pt, default 10) and `color` +(`#RRGGBB`, default black) for `text` and `date`. The colour is saved in the +FreeText appearance and survives reopening. Other mark/signature types reject +`color`; their appearance controls are separate. diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index f2c9f6b77d..e52768d535 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -5,7 +5,7 @@ impl Automation { let path = self.resolve(a.str("path")?, true)?; let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) - .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?; + .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html, rtf or md)")))?; let export = self.doc_to_copy(a)?.export_office_checked(format); write_atomic(&path, &export.bytes)?; // Text left out to keep a hostile page bounded is said, not silent (#3120). diff --git a/crates/automation/src/batch.rs b/crates/automation/src/batch.rs index 47cf714ec9..decb18d2b7 100644 --- a/crates/automation/src/batch.rs +++ b/crates/automation/src/batch.rs @@ -17,6 +17,7 @@ const MAX_INPUT: u64 = 512 * 1024 * 1024; struct Recipe<'a> { steps: Vec<&'a str>, images: optimize::ImageSettings, + mono: optimize::MonoSettings, ocr: ocr::OcrSettings, } @@ -38,12 +39,17 @@ impl<'a> Recipe<'a> { } } let mut images = optimize::ImageSettings::REDUCE; + let mut mono = optimize::MonoSettings::REDUCE; if let Some(ppi) = args.opt_num("ppi")? { if !(9.0..=1600.0).contains(&ppi) { return Err(Args::wrong("ppi", "9 to 1600")); } images.target_ppi = ppi; images.above_ppi = ppi * 1.5; + // Black-and-white images keep Reduce File Size's higher resolution, at which text + // stays legible, and are never taken below a higher `ppi` asked for. + mono.target_ppi = mono.target_ppi.max(ppi); + mono.above_ppi = mono.target_ppi * 1.5; } if let Some(q) = args.opt_int("quality")? { if !(1..=100).contains(&q) { @@ -54,7 +60,7 @@ impl<'a> Recipe<'a> { if args.opt_num("dpi")?.is_some_and(|dpi| !(72.0..=600.0).contains(&dpi)) { return Err(Args::wrong("dpi", "72 to 600")); } - Ok(Self { steps, images, ocr: auto.ocr_settings(args)? }) + Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)? }) } fn accepts(&self, path: &Path) -> bool { @@ -88,7 +94,7 @@ impl<'a> Recipe<'a> { } "optimize" => { let id = session.open(name, None, bytes.clone(), None).map_err(failed)?; - let settings = optimize::Settings { color: self.images, gray: self.images, ..Default::default() }; + let settings = optimize::Settings { color: self.images, gray: self.images, mono: self.mono, ..Default::default() }; let (candidate, _) = session.optimized_bytes(id, &settings, &[])?; session.close(id); if candidate.len() < bytes.len() { diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 0150001096..f87fd0b58e 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -502,6 +502,10 @@ impl Automation { if sensitivity.is_some() && !scan { return Err(ToolError::InvalidArgs("sensitivity is only for scan: true".into())); } + let requested_color = a.color("color")?; + if requested_color.is_some() && !matches!(kind, "text" | "date") { + return Err(ToolError::InvalidArgs("color is only for type text or date".into())); + } let requested_size = a.opt_num("font_size")?; if requested_size.is_some() && !matches!(kind, "text" | "date") { return Err(ToolError::InvalidArgs("font_size is only for type text or date".into())); @@ -579,7 +583,10 @@ impl Automation { (Shape::Mark { rect: mark.rect_at(at, i64::from(info.rotation)), mark }, String::new()) } }; - let style = Style::default_for(&shape); + let mut style = Style::default_for(&shape); + if let Some(color) = requested_color { + style.color = color; + } self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author })) } diff --git a/crates/automation/src/conventions.rs b/crates/automation/src/conventions.rs index ac670109fb..a84c6488a4 100644 --- a/crates/automation/src/conventions.rs +++ b/crates/automation/src/conventions.rs @@ -1,7 +1,7 @@ //! Shared command/inspection/preview entry points, using the existing confined tool path. use serde_json::{Value, json}; -use crate::{Args, Automation, Content, Result, ToolError, encode_png, failed, info, tools}; +use crate::{Args, Automation, Content, Result, ToolError, failed, info, rendered_content, tools}; use pdfcraft_engine::commands; use pdfcraft_render::{RenderRequest, RequestKind}; @@ -108,7 +108,8 @@ impl Automation { Ok(json!({"documents":self.session.docs().iter().map(info).collect::>()})) } - pub(crate) fn render_preview(&mut self, args: &Args) -> Result { + /// A bounded PNG of the page, with its render warnings after it as `page_render` gives them. + pub(crate) fn render_preview(&mut self, args: &Args) -> Result> { let id = if args.opt_int("doc")?.is_some() { self.doc(args)?.id } else { @@ -134,7 +135,6 @@ impl Automation { if let Some(error) = out.error { return Err(failed(error)); } - let data = encode_png(out.width, out.height, &out.rgba)?; - Ok(Content::Png { data, width: out.width, height: out.height }) + rendered_content(&out, page) } } diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index b136076bc8..63602e52d6 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -38,7 +38,7 @@ impl Automation { let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() }; self.apply(&a, edit)? } - "page_render" => return self.page_render(&a).map(|c| vec![c]), + "page_render" => return self.page_render(&a), "comment_image_preview" => return self.comment_image_preview(&a), "text_extract" => self.text_extract(&a)?, "text_word_count" => self.text_word_count(&a)?, @@ -142,7 +142,7 @@ impl Automation { "command_run" => return self.command_run(&a), "command_batch" => self.command_batch(&a)?, "doc_inspect" => self.doc_inspect(&a)?, - "render_preview" => return self.render_preview(&a).map(|c| vec![c]), + "render_preview" => return self.render_preview(&a), "page_number" => { use pdfcraft_organize::LabelStyle as L; let n = self.doc(&a)?.info.pages.len(); @@ -636,6 +636,7 @@ impl Automation { } "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, + "doc_backdrops" => self.backdrops(&a)?, "doc_unprotect" => { let mut out = self.apply(&a, Edit::RemoveProtection)?; out["security"] = security(self.doc(&a)?); diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 476c451028..835547968f 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -279,7 +279,8 @@ impl Automation { } pub(crate) fn doc_optimize(&mut self, a: &Args) -> Result { - use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset}; + use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, MonoCompression, Preset}; + const MONO_KEYS: [&str; 4] = ["downsample", "ppi", "above_ppi", "compression"]; let id = self.doc(a)?.id; let before = self.doc(a)?.bytes.len(); let path = self.resolve(a.str("path")?, true)?; @@ -317,6 +318,37 @@ impl Automation { }; settings.color = images("color", settings.color)?; settings.gray = images("gray", settings.gray)?; + if let Some(mono) = a.get("mono") { + let unknown = |c: &Value| ToolError::InvalidArgs(format!("unknown mono compression {c} (ccitt_g4, retain)")); + let mono = mono.as_object().ok_or_else(|| { + ToolError::InvalidArgs("mono must be an object: { downsample, ppi, above_ppi, compression: ccitt_g4|retain }".into()) + })?; + // A misspelled key would otherwise leave the preset's setting in place unnoticed. + if let Some(key) = mono.keys().find(|k| !MONO_KEYS.contains(&k.as_str())) { + return Err(ToolError::InvalidArgs(format!("unknown mono key {key:?} ({})", MONO_KEYS.join(", ")))); + } + let m = &mut settings.mono; + if let Some(d) = mono.get("downsample") { + m.downsample = d.as_bool().ok_or_else(|| ToolError::InvalidArgs(format!("mono downsample must be true or false, not {d}")))?; + } + for (key, ppi) in [("ppi", &mut m.target_ppi), ("above_ppi", &mut m.above_ppi)] { + if let Some(p) = mono.get(key) { + *ppi = p + .as_f64() + .filter(|p| p.is_finite()) + .ok_or_else(|| ToolError::InvalidArgs(format!("mono {key} must be a number, not {p}")))? + .clamp(9.0, 2400.0); + } + } + m.above_ppi = m.above_ppi.max(m.target_ppi); + if let Some(c) = mono.get("compression") { + m.compression = match c.as_str() { + Some("ccitt_g4" | "ccitt" | "g4") => MonoCompression::CcittG4, + Some("retain") => MonoCompression::Retain, + _ => return Err(unknown(c)), + }; + } + } for (key, flag) in [ ("merge_fragments", &mut settings.merge_fragments), ("discard_thumbnails", &mut settings.discard_thumbnails), @@ -416,6 +448,28 @@ impl Automation { Ok(out) } + /// Pages whose own backdrop hides a background (see `pdfcraft_edit::backdrop`). + pub(crate) fn backdrops(&mut self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let doc = self.doc(a)?; + let found: Vec = pages + .iter() + .zip(doc.page_backdrops(&pages)) + .filter_map(|(p, found)| { + let kind = match found? { + pdfcraft_engine::Backdrop::Fill => "fill", + pdfcraft_engine::Backdrop::Image => "image", + }; + Some(json!({ "page": p + 1, "backdrop": kind })) + }) + .collect(); + Ok(json!({ "pages": found })) + } + pub(crate) fn marks(&mut self, tool: &str, a: &Args) -> Result { use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark}; let n = self.doc(a)?.info.pages.len(); @@ -483,6 +537,7 @@ impl Automation { color: color("color", [1.0; 3])?, opacity: a.opt_num("opacity")?.unwrap_or(1.0), scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0), + replace_backdrop: a.opt_bool("replace_backdrop")?.unwrap_or(true), ..Background::default() }, replace, diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index e1ae0141c4..f1910867c9 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -62,7 +62,7 @@ use error::{Result, failed}; pub(crate) use json::destination; pub use json::view_rect; use json::{Args, info, summary}; -use pages::{DEFAULT_DPI, MAX_DPI, encode_png}; +use pages::{DEFAULT_DPI, MAX_DPI, encode_png, rendered_content}; #[cfg(test)] use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel}; #[cfg(test)] diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 647a72e1d4..8c793be153 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -65,7 +65,9 @@ impl Automation { Ok(&mut entry.1) } - pub(crate) fn page_render(&mut self, a: &Args) -> Result { + /// The page as a PNG, followed by a JSON part listing the render warnings when there are any, + /// so a caller can tell that the picture is incomplete, as `pdfcraft-cli render` does (#4340). + pub(crate) fn page_render(&mut self, a: &Args) -> Result> { let id = self.doc(a)?.id; let page = self.page(a)?; let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI); @@ -98,8 +100,7 @@ impl Automation { if let Some(e) = out.error { return Err(failed(format!("page {} could not be rendered: {e}", page + 1))); } - let data = encode_png(out.width, out.height, &out.rgba)?; - Ok(Content::Png { data, width: out.width, height: out.height }) + rendered_content(&out, page) } /// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels @@ -324,6 +325,29 @@ pub(crate) fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Resul pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed) } +/// The output of a page-rendering tool: the PNG of the 0-based `page`, followed by a JSON part +/// listing its render warnings when there are any. Without warnings it is the PNG alone, as it +/// always was. +pub(crate) fn rendered_content(out: &pdfcraft_render::RenderedPage, page: usize) -> Result> { + let data = encode_png(out.width, out.height, &out.rgba)?; + let mut content = vec![Content::Png { data, width: out.width, height: out.height }]; + if !out.warnings.is_empty() { + // Sentences, like the other tools' `warnings`, so command_run can add its own to the list. + let warnings: Vec<&str> = out.warnings.iter().map(|&w| render_warning_text(w)).collect(); + content.push(Content::Json(json!({ "page": page.saturating_add(1), "warnings": warnings }))); + } + Ok(content) +} + +/// What a render warning means, in the words `pdfcraft-cli render` prints for it. +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::ColourFallback => "a colour that could not be computed was painted in a fallback colour", + } +} + /// Extract the text of `pages` (0-based) with one renderer per worker thread. pub(crate) fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> PageTexts { let extract = |r: &mut PageRenderer, p: usize| { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 53c4582af9..554496b4b3 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -121,7 +121,7 @@ pub fn tools() -> Vec { let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." }); let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." }); vec![ - t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") + t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") .with(schema(json!({ "paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000}, "out_dir": {"type":"string"}, @@ -167,7 +167,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. 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).") + 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, ...] }.") .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." }, @@ -919,7 +919,7 @@ pub fn tools() -> Vec { )), t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") .ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])), - t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), + t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), t( "doc_protect", "Protect with passwords", @@ -1029,12 +1029,16 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. Undoable.") + t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. A page that paints its own opaque white backdrop would hide it, so that backdrop is replaced unless replace_backdrop is false (removing the background turns it back on where that is still safe, and doc_remove_marks warns about the pages where it isn't); a scanned page's picture still hides it (doc_backdrops lists both). Undoable.") .cmd("edit.background") .with(schema( - json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" } }), + json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }), &["doc"], )), + t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.") + .ro() + .cmd("edit.background") + .with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])), t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])), @@ -1187,7 +1191,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), &["doc", "field", "event"], )), - t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.") + t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.") .cmd("export.docx") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.") @@ -1274,7 +1278,7 @@ pub fn tools() -> Vec { .ro() .cmd("ocr.recognize") .with(schema(json!({}), &[])), - t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.") + t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, separations) are listed under skipped. Includes unsaved edits.") .cmd("export.all_images") .with(schema( json!({ @@ -1291,7 +1295,7 @@ pub fn tools() -> Vec { t( "fill_sign_add", "Fill & Sign: type text or place a mark", - "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", + "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", ) .cmd("sign.fill.text") .with(schema( @@ -1302,6 +1306,7 @@ pub fn tools() -> Vec { "at": point(), "text": { "type": "string", "minLength": 1 }, "font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." }, + "color": color(), "path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." }, "scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." }, "sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." }, @@ -1332,7 +1337,7 @@ pub fn tools() -> Vec { }), &["from"], )), - t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.") + t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: colour and grey images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), black-and-white images averaged down to 300 ppi above 450 ppi and each stored as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) whenever that is smaller, thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.") .cmd("optimize.reduce") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema( @@ -1433,7 +1438,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ @@ -1444,6 +1449,7 @@ pub fn tools() -> Vec { "merge_fragments": { "type": "boolean" }, "color": { "type": "object" }, "gray": { "type": "object" }, + "mono": { "type": "object" }, "discard_thumbnails": { "type": "boolean" }, "discard_alternate_images": { "type": "boolean" }, "discard_tags": { "type": "boolean" }, @@ -1478,7 +1484,7 @@ pub fn tools() -> Vec { .destructive().with(schema(json!({"steps":{"type":"array","items":schema(json!({"id":{"type":"string"},"params":{"type":"object"}}), &["id"])},"stop_on_error":{"type":"boolean"}}), &["steps"])), t("doc_inspect", "Inspect open documents", "With doc, return doc_info; otherwise return information for every open document (empty documents array when none are open).") .ro().with(schema(json!({"doc":doc()}), &[])), - t("render_preview", "Render a preview", "Render a PNG bounded by max_side (default 1024), without changing or saving the document. Page defaults to 1; doc may be omitted when exactly one document is open.") + t("render_preview", "Render a preview", "Render a PNG bounded by max_side (default 1024), without changing or saving the document. Page defaults to 1; doc may be omitted when exactly one document is open. Render warnings follow the image as page_render gives them.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[])), ] } diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 01d0d1eb24..a3cd8dcd32 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2226,6 +2226,35 @@ fn nested_replies_stay_in_the_listing() { assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}"); } +/// comment_delete on a reply deletes that reply and the replies to it, as the Comments panel's +/// reply menu does: the comment and its other replies stay, and one undo brings it back. +#[test] +fn deleting_a_reply_keeps_its_comment_and_the_other_replies() { + let dir = workdir("delete-reply"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let root = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [40, 40], "contents": "ROOT_NOTE", "author": "A" })); + let root_id = root["comment"]["id"].as_str().unwrap().to_string(); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "WRONG_REPLY", "author": "B" })); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "RIGHT_REPLY", "author": "C" })); + let replies = |a: &mut Automation| -> Vec { + let list = ok(a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1, "{list}"); + list["comments"][0]["replies"].as_array().unwrap().clone() + }; + let wrong = replies(&mut a).into_iter().find(|r| r["contents"] == "WRONG_REPLY").unwrap(); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": wrong["id"], "text": "ANSWER_TO_WRONG", "author": "D" })); + let texts = |a: &mut Automation| -> Vec { replies(a).iter().filter_map(|r| r["contents"].as_str().map(str::to_owned)).collect() }; + assert_eq!(texts(&mut a), ["WRONG_REPLY", "RIGHT_REPLY", "ANSWER_TO_WRONG"]); + + ok(&mut a, "comment_delete", json!({ "doc": doc, "page": wrong["page"], "index": wrong["index"] })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["comments"][0]["contents"], "ROOT_NOTE", "{list}"); + assert_eq!(texts(&mut a), ["RIGHT_REPLY"], "the reply took the answer to it along"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Delete comment"); + assert_eq!(texts(&mut a), ["WRONG_REPLY", "RIGHT_REPLY", "ANSWER_TO_WRONG"]); +} + /// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every /// page `fitz-A0`. A shared id is refused rather than taken to mean page 1's comment. #[test] @@ -3667,6 +3696,38 @@ fn fill_sign_text_font_size_is_saved_and_validated() { )); } +#[test] +fn fill_sign_text_and_date_colours_are_saved_and_validated() { + let dir = workdir("fill-sign-text-colour"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (kind, y) in [("text", 40), ("date", 80)] { + ok( + &mut a, + "fill_sign_add", + json!({ "doc": doc, "page": 1, "type": kind, "at": [20, y], "text": "Blue", "color": "#3366CC", "font_size": 6.5 }), + ); + } + for (kind, color) in [("text", "not-a-colour"), ("signature", "#3366CC"), ("check", "#3366CC")] { + assert!(matches!( + a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": kind, "at": [20, 100], "text": "No", "color": color })), + Err(ToolError::InvalidArgs(_)) + )); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "coloured.pdf" })); + let pdf = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("coloured.pdf")).unwrap())).unwrap(); + let page = pdfcraft_model::pages(&pdf).remove(0); + let annotations = pdf.resolve(page.dict.get(b"Annots").unwrap()); + let annotations = annotations.as_array().unwrap(); + assert_eq!(annotations.len(), 2, "invalid requests add nothing"); + for reference in annotations { + let annotation = pdf.resolve(reference); + // FreeText stores its text colour in DA; C is the annotation background. + let da = annotation.as_dict().unwrap().get(b"DA").unwrap().as_string().unwrap(); + assert_eq!(String::from_utf8(da.bytes.to_vec()).unwrap(), "0.2 0.4 0.8 rg /Helv 6.5 Tf"); + } +} + #[test] fn image_signatures_through_tools_preserve_transparency_and_survive_save() { let dir = workdir("image-signatures"); @@ -6372,6 +6433,207 @@ fn lzw_streams_are_stored_with_flate_through_tools() { assert_eq!(filters("kept.pdf"), (Some(Object::name("LZWDecode")), ops)); } +/// A one-page PDF drawing a 1-bit scan of text (glyph strokes with ragged edges, as a scanner +/// leaves them) of 1000 × 400 samples, stored with Flate. +fn scanned_page() -> Vec { + scan_drawn(b"q 500 0 0 200 50 500 cm /Scan Do Q") +} + +/// [`scanned_page`]'s scan, drawn by `content` (which names it `/Scan`). +fn scan_drawn(content: &[u8]) -> Vec { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full}; + let (w, h) = (1000usize, 400usize); + let stride = w.div_ceil(8); + let mut data = vec![0xFFu8; stride * h]; + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for line in (4..h - 20).step_by(24) { + let mut x = 8; + while x + 12 < w { + let wide = 3 + (next() % 5) as usize; + let (top, bottom) = (line + (next() % 4) as usize, line + 14); + for y in top..bottom { + let left = x + usize::from(next() % 8 == 0); + let right = x + wide - usize::from(next() % 8 == 0); + for xx in left..right { + data[y * stride + xx / 8] &= !(0x80 >> (xx % 8)); + } + } + x += wide + 2 + (next() % 4) as usize; + } + } + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::flate(d, &data))); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let mut x = Dict::new(); + x.set(b"Scan".to_vec(), Object::Ref(image)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.to_vec()))); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +/// A scan saved as a 1-bit PNG becomes a page whose picture stays one bit per pixel (not the eight +/// bits the PNG decodes to), and Export all images gives the picture back. +#[test] +fn a_one_bit_scan_stays_one_bit_and_exports() { + let dir = workdir("bilevel"); + let (w, h) = (800u32, 200u32); + let stride = (w as usize).div_ceil(8); + let mut bits = vec![0xFFu8; stride * h as usize]; + for y in 0..h as usize { + for x in 0..w as usize { + if y % 25 >= 6 && y % 25 < 18 && (x * 7 + y / 25 * 13) % 11 < 4 { + bits[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let mut png_bytes = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png_bytes, w, h); + enc.set_color(png::ColorType::Grayscale); + enc.set_depth(png::BitDepth::One); + enc.write_header().unwrap().write_image_data(&bits).unwrap(); + } + std::fs::write(dir.join("scan.png"), &png_bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["scan.png"] }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "scan.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("scan.pdf")).unwrap())).unwrap(); + let images: Vec> = saved + .object_numbers() + .into_iter() + .filter_map(|num| match &*saved.get(pdfcraft_cos::ObjRef { num, generation: saved.generation(num) }) { + pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some(s.dict.int(b"BitsPerComponent")), + _ => None, + }) + .collect(); + assert_eq!(images, [Some(1)]); + let r = ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": "out" })); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{r}"); + let exported = std::fs::read(files[0]["path"].as_str().unwrap()).unwrap(); + let mut reader = png::Decoder::new(std::io::Cursor::new(exported)).read_info().unwrap(); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut buf).unwrap(); + let white: Vec = buf[..frame.buffer_size()].as_chunks::<3>().0.iter().map(|p| *p == [255, 255, 255]).collect(); + let expected: Vec = + (0..h as usize).flat_map(|y| (0..w as usize).map(move |x| (y, x))).map(|(y, x)| bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect(); + assert_eq!(white, expected); +} + +/// A scan's black-and-white image is stored as CCITT Group 4 by default, and kept as it is with +/// `mono: { compression: retain }`. +#[test] +fn doc_optimize_stores_black_and_white_images_as_ccitt_group_4() { + let dir = workdir("optimize-mono"); + std::fs::write(dir.join("scan.pdf"), scanned_page()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap(); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "mono": { "compression": "retain" } })); + assert_eq!((kept["images"].as_u64(), kept["images_recompressed"].as_u64()), (Some(1), Some(0)), "{kept}"); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" })); + assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}"); + assert!(r["image_bytes_after"].as_u64() < r["image_bytes_before"].as_u64(), "{r}"); + // The copy written stores the image as Group 4. + let small = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("small.pdf")).unwrap())).unwrap(); + let filters: Vec> = small + .object_numbers() + .into_iter() + .filter_map(|num| match &*small.get(pdfcraft_cos::ObjRef { num, generation: small.generation(num) }) { + pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => s.dict.name(b"Filter").map(<[u8]>::to_vec), + _ => None, + }) + .collect(); + assert_eq!(filters, [b"CCITTFaxDecode".to_vec()]); + // An unknown compression, one that isn't a name, a misspelled key, or a mono that isn't an + // object is refused, not read as the default. + for mono in [json!({ "compression": "jbig2" }), json!({ "compression": false }), json!({ "compresson": "retain" }), json!("retain")] { + assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": mono })), Err(ToolError::InvalidArgs(_))), "{mono}"); + } +} + +/// A scan drawn at 600 ppi is averaged down to 300 ppi by default. `mono: { downsample: false }` +/// keeps its size, `ppi` and `above_ppi` choose others, and values of the wrong type are refused. +#[test] +fn doc_optimize_averages_black_and_white_scans_down() { + let dir = workdir("optimize-mono-downsample"); + // 1000 × 400 samples on 120 × 48 pt: 600 ppi. + std::fs::write(dir.join("scan.pdf"), scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap(); + let sizes = |name: &str| image_sizes(&dir.join(name)); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" })); + assert_eq!(r["images_resampled"].as_u64(), Some(1), "{r}"); + assert_eq!(sizes("small.pdf"), [(Some(500), Some(200))]); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "mono": { "downsample": false } })); + assert_eq!(r["images_resampled"].as_u64(), Some(0), "{r}"); + assert_eq!(sizes("kept.pdf"), [(Some(1000), Some(400))]); + ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "200.pdf", "mono": { "ppi": 200, "above_ppi": 250 } })); + assert_eq!(sizes("200.pdf"), [(Some(333), Some(133))]); + for mono in [json!({ "downsample": "yes" }), json!({ "ppi": "300" }), json!({ "above": 450 })] { + assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": mono })), Err(ToolError::InvalidArgs(_))), "{mono}"); + } +} + +/// The width and height of every image in the PDF at `path`. +fn image_sizes(path: &std::path::Path) -> Vec<(Option, Option)> { + let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap(); + written + .object_numbers() + .into_iter() + .filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) { + pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some((s.dict.int(b"Width"), s.dict.int(b"Height"))), + _ => None, + }) + .collect() +} + +/// The batch optimize step's `ppi` reaches black-and-white images too: they go to the larger of +/// it and Reduce File Size's 300 ppi. Before, a batch run took every scan to 300 ppi whatever +/// `ppi` said, so `ppi: 600` halved 600 ppi scans. +#[test] +fn batch_ppi_bounds_how_far_black_and_white_scans_are_resampled() { + let dir = workdir("batch-mono-ppi"); + // 1000 × 400 samples on 120 × 48 pt: 600 ppi. + std::fs::write(dir.join("scan.pdf"), scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap(); + let mut a = auto(&dir); + for (ppi, size) in [(None, (500, 200)), (Some(600), (1000, 400)), (Some(96), (500, 200))] { + let out = format!("out-{ppi:?}"); + let mut args = json!({ "paths": ["scan.pdf"], "steps": ["optimize"], "out_dir": out }); + if let Some(ppi) = ppi { + args["ppi"] = json!(ppi); + } + let report = ok(&mut a, "doc_batch", args); + assert_eq!(report["completed"], 1, "{report}"); + assert_eq!(image_sizes(&dir.join(&out).join("scan.pdf")), [(Some(size.0), Some(size.1))], "ppi {ppi:?}"); + } +} + /// A one-page PDF whose page picture is drawn as `rows` grey images one pixel row high, 600 px /// wide on a 72 pt square (600 ppi), as some PDF printers write it. fn printed_in_strips(rows: usize) -> Vec { @@ -6974,7 +7236,7 @@ fn exporting_to_word_html_and_rtf() { let dir = workdir("office"); let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - for ext in ["docx", "pptx", "html", "rtf"] { + for ext in ["docx", "pptx", "html", "rtf", "md"] { let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("a.{ext}") })); assert_eq!(r["format"], ext); } @@ -6983,6 +7245,10 @@ fn exporting_to_word_html_and_rtf() { assert!(std::fs::read(dir.join("a.docx")).unwrap().starts_with(b"PK")); assert!(std::fs::read(dir.join("a.pptx")).unwrap().starts_with(b"PK")); assert!(std::fs::read_to_string(dir.join("a.rtf")).unwrap().contains("Page 2")); + // #1595: Markdown, with a rule between pages. + let md = std::fs::read_to_string(dir.join("a.md")).unwrap(); + assert!(md.contains("Page 1") && md.contains("Page 3") && md.matches("\n---\n").count() == 2, "{md}"); + assert_eq!(ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "b.markdown" }))["format"], "md"); assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err()); } diff --git a/crates/automation/tests/it/backdrop.rs b/crates/automation/tests/it/backdrop.rs new file mode 100644 index 0000000000..00570b260c --- /dev/null +++ b/crates/automation/tests/it/backdrop.rs @@ -0,0 +1,269 @@ +//! Add Background on pages that paint their own backdrop, end to end through the tools: what +//! `page_render` draws, `doc_backdrops`, save and reopen, and Remove putting the page back. + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// Two 200 × 100 pt pages saying "Hi": page 1 on a white fill of its own (as browsers write), +/// page 2 on bare paper; a third page is a "scan" (a page-sized grey picture). +fn fixture() -> Vec { + let text = "0 g BT /F1 24 Tf 20 40 Td (Hi) Tj ET"; + let contents = [format!("q 1 1 1 rg 0 0 200 100 re f Q {text}"), text.to_string(), format!("q 200 0 0 100 0 0 cm /Im0 Do Q {text}")]; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [4 0 R 6 0 R 8 0 R] /Count 3 /MediaBox [0 0 200 100] >>".into(), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{80}\nendstream" + .into(), + ]; + for (i, c) in contents.iter().enumerate() { + objs.push(format!( + "<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 3 0 R >> >> >>", + 5 + 2 * i + )); + objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn workdir() -> PathBuf { + named_workdir("main") +} + +/// A folder of its own per test: the tests of this binary run at the same time. +fn named_workdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-automation-backdrop-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.pdf"), fixture()).unwrap(); + dir +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +/// The RGB of a blank spot (top-right corner area) of a page rendered at 72 dpi. +fn blank_spot(a: &mut Automation, doc: u64, page: u64) -> [u8; 3] { + let rendered = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap(); + let Content::Png { data, width, .. } = &rendered[0] else { panic!("expected a PNG") }; + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + let channels = info.color_type.samples(); + let at = (10 * *width as usize + 180) * channels; + [pixels[at], pixels[at + 1], pixels[at + 2]] +} + +const RED: [u8; 3] = [255, 0, 0]; +const WHITE: [u8; 3] = [255, 255, 255]; + +#[test] +fn a_background_shows_on_a_page_that_paints_its_own_white_backdrop() { + let dir = workdir(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!( + ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], + json!([{ "page": 1, "backdrop": "fill" }, { "page": 3, "backdrop": "image" }]) + ); + + // Not replacing: the page's own white hides the background, as it always did. + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000", "replace_backdrop": false })); + assert_eq!(blank_spot(&mut a, doc, 1), WHITE); + assert_eq!(blank_spot(&mut a, doc, 2), RED); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + + // The default replaces it: red on both pages; the scan still hides it. + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" })); + assert_eq!(blank_spot(&mut a, doc, 1), RED); + assert_eq!(blank_spot(&mut a, doc, 2), RED); + assert_ne!(blank_spot(&mut a, doc, 3), RED); + assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([{ "page": 3, "backdrop": "image" }])); + + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(blank_spot(&mut a, saved, 1), RED, "after save and reopen"); + ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" })); + assert_eq!(blank_spot(&mut a, saved, 1), WHITE); + assert_eq!( + ok(&mut a, "doc_backdrops", json!({ "doc": saved, "pages": [1] }))["pages"], + json!([{ "page": 1, "backdrop": "fill" }]), + "the backdrop is back" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// One 200 × 100 pt page on a white fill of its own: a line to redact, invisible (`3 Tr`) text +/// that Remove Hidden Information removes, and a line that stays (`0 Tr` again: the render mode +/// outlives `ET`). With `tag`, the content is a +/// backdrop copy carrying that tag, under a background. +fn secret_pdf(tag: Option<&str>) -> Vec { + let fill = if tag.is_some() { "n" } else { "f" }; + let content = format!( + "1 1 1 rg 0 0 200 100 re {fill} 0 g BT /F1 12 Tf 10 70 Td (SECRET-LINE) Tj ET BT 3 Tr /F1 12 Tf 10 45 Td (HIDDEN-LINE) Tj ET BT 0 Tr /F1 12 Tf 10 20 Td (KEEP) Tj ET" + ); + let bg = "q 1 0 0 rg 0 0 200 100 re f Q"; + let (contents, extra) = match tag { + Some(t) => ( + "[5 0 R 6 0 R]", + vec![ + format!("<< /PCMark /Background /Length {} >>\nstream\n{bg}\nendstream", bg.len()), + format!("<< /PCBackdrop {t} /Length {} >>\nstream\n{content}\nendstream", content.len()), + ], + ), + None => ("[5 0 R]", vec![format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())]), + }; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 100] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /Font << /F1 4 0 R >> >> >>"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]; + objs.extend(extra); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// Every stream of a saved file, decoded, joined: what anyone reading the file can recover. +fn every_stream(path: &std::path::Path) -> String { + let doc = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap(); + doc.scan_objects() + .filter_map(|(_, o)| match &*o { + pdfcraft_cos::Object::Stream(s) => Some(String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned()), + _ => None, + }) + .collect::>() + .join("\n") +} + +/// Text a redaction or Remove Hidden Information took off a page whose backdrop a background +/// replaced is gone from the saved file. The backdrop copy's tag used to keep a reference to the +/// page's original content stream, and with it the removed text. Removing the background +/// afterwards still turns the page's white fill back on. +#[test] +fn removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop() { + for (remove, gone) in [ + (vec![("redact_mark", json!({ "find": "SECRET-LINE" })), ("redact_apply", json!({}))], "SECRET-LINE"), + (vec![("doc_remove_hidden", json!({ "categories": ["hidden-text"] }))], "HIDDEN-LINE"), + ] { + let dir = named_workdir(gone); + std::fs::write(dir.join("s.pdf"), secret_pdf(None)).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "s.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" })); + for (tool, args) in &remove { + let mut args = args.clone(); + args["doc"] = json!(doc); + ok(&mut a, tool, args); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true })); + let file = every_stream(&dir.join("out.pdf")); + assert!(file.contains("(KEEP)"), "{gone}: the page's other text is still there: {file}"); + assert!(!file.contains(gone), "{gone} is still in the file: {file}"); + + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap(); + let removed = ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" })); + assert!(removed.get("warnings").is_none(), "{gone}: {removed}"); + assert_eq!( + ok(&mut a, "doc_backdrops", json!({ "doc": saved }))["pages"], + json!([{ "page": 1, "backdrop": "fill" }]), + "{gone}: the fill is back" + ); + assert_eq!(blank_spot(&mut a, saved, 1), WHITE); + let _ = std::fs::remove_dir_all(&dir); + } +} + +/// When the backdrop can't be turned back on safely, Remove background says which pages keep it +/// off. +#[test] +fn remove_background_warns_about_pages_whose_own_backdrop_stays_off() { + let dir = named_workdir("left-off"); + // A tag whose hash doesn't match the bytes before the fill: they changed since. + let tag = format!("<< /Index 2 /Op (f) /Before <{}> >>", "00".repeat(32)); + std::fs::write(dir.join("t.pdf"), secret_pdf(Some(&tag))).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "t.pdf" }))["doc"].as_u64().unwrap(); + let removed = ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "background" })); + let warnings = removed["warnings"].as_array().unwrap_or_else(|| panic!("{removed}")); + assert_eq!(warnings.len(), 1, "{removed}"); + assert!(warnings[0].as_str().unwrap().starts_with("page 1 changed after the background was added"), "{removed}"); + assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([]), "the fill stays off"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Two 200 × 100 pt pages whose full-page `1 g` fill is not a plain white backdrop (#2815 +/// review): page 1 paints it half transparent, through a `gs` that sets `ca` and a later one +/// that only sets the line width; page 2 sends it through a transfer function that maps 1 to 0. +fn graphics_state_pdf() -> Vec { + let invert = "<< /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>"; + let resources = format!("<< /ExtGState << /Transparent << /ca 0.5 >> /LineOnly << /LW 2 >> /Invert << /TR {invert} >> >> >>"); + let contents = ["/Transparent gs /LineOnly gs 1 g 0 0 200 100 re f", "/Invert gs 1 g 0 0 200 100 re f"]; + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 200 100] >>".into()]; + for (i, c) in contents.iter().enumerate() { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", 4 + 2 * i)); + objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #2815 review: a translucent fill (its `ca` left in force by a later `gs`) and a fill through +/// a transfer function are not backdrops, so a red background goes under them and the +/// translucent white still lightens it. +#[test] +fn fills_the_graphics_state_changes_are_not_replaced() { + let dir = named_workdir("graphics-state"); + std::fs::write(dir.join("gs.pdf"), graphics_state_pdf()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "gs.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([]), "neither fill is a backdrop"); + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" })); + let [r, g, b] = blank_spot(&mut a, doc, 1); + assert_eq!(r, 255, "{:?}", [r, g, b]); + assert!((100..=160).contains(&g) && (100..=160).contains(&b), "half-transparent white over red, not red: {:?}", [r, g, b]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index b48b23bf0a..d36dbe93a9 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -3,6 +3,7 @@ mod attachment_key; mod automation; mod automation_roots; +mod backdrop; mod batch; mod button_caption; mod form_bulk; @@ -10,3 +11,4 @@ mod hostile_args; mod optimize_content_boundaries; mod redact_after_stray_q_form; mod redact_text_after_q; +mod render_warnings; diff --git a/crates/automation/tests/it/render_warnings.rs b/crates/automation/tests/it/render_warnings.rs new file mode 100644 index 0000000000..ff50c11e16 --- /dev/null +++ b/crates/automation/tests/it/render_warnings.rs @@ -0,0 +1,119 @@ +//! `page_render` reports the renderer's warnings next to the PNG, so a headless caller can tell +//! that a picture is incomplete or approximate, as `pdfcraft-cli render` can (#4340). + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// The warning a colour that falls back gives, worded as `pdfcraft-cli render` prints it. +const FALLBACK: &str = "a colour that could not be computed was painted in a fallback colour"; + +/// A one-page 200 × 200 pt PDF that draws `content`, with the colour space `CS1` defined as +/// `[/DeviceN [/Spot1 /Spot2] /DeviceRGB 5 0 R]` and object 5 a calculator function `program`. +fn devicen_pdf(content: &str, program: &str) -> Vec { + 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 << /ColorSpace << /CS1 [/DeviceN [/Spot1 /Spot2] /DeviceRGB 5 0 R] >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] /Length {} >> stream\n{program}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + program.len() + ) + .into_bytes() +} + +/// A page whose colour falls back: the tint transform pops more than it is given, so it can't be +/// evaluated (the renderer's own test of the warning uses the same function). +fn fallback_pdf() -> Vec { + devicen_pdf("/CS1 cs 0.5 0.5 scn 0 0 100 100 re f", "{ pop pop pop }") +} + +/// The same page with a tint transform that works: nothing to warn about. +fn clean_pdf() -> Vec { + devicen_pdf("/CS1 cs 0.5 0.5 scn 0 0 100 100 re f", "{ pop pop 0 0 0 }") +} + +/// A fresh folder holding `fallback.pdf` and `clean.pdf`, and an automation rooted there. +fn setup(test: &str) -> (PathBuf, Automation) { + let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("fallback.pdf"), fallback_pdf()).unwrap(); + std::fs::write(dir.join("clean.pdf"), clean_pdf()).unwrap(); + let a = Automation::new().with_root(&dir).unwrap(); + (dir, a) +} + +fn open(a: &mut Automation, path: &str) -> u64 { + match a.call("doc_open", &json!({ "path": path })).unwrap().remove(0) { + Content::Json(v) => v["doc"].as_u64().unwrap(), + other => panic!("expected JSON: {other:?}"), + } +} + +#[test] +fn page_render_lists_the_warnings_after_the_png() { + let (dir, mut a) = setup("fallback"); + let doc = open(&mut a, "fallback.pdf"); + let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + assert_eq!(out.len(), 2, "the PNG and the warnings: {out:?}"); + assert!(matches!(out[0], Content::Png { width: 200, height: 200, .. }), "the image comes first, as before"); + let Content::Json(report) = &out[1] else { panic!("expected the warnings as JSON: {:?}", out[1]) }; + assert_eq!(report["page"], 1, "the page is 1-based, like the argument"); + assert_eq!(report["warnings"], json!([FALLBACK])); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn render_preview_lists_the_warnings_after_the_png_too() { + let (dir, mut a) = setup("preview"); + let doc = open(&mut a, "fallback.pdf"); + let out = a.call("render_preview", &json!({ "doc": doc, "max_side": 64 })).unwrap(); + assert!(matches!(out.first(), Some(Content::Png { width: 64, height: 64, .. })), "{out:?}"); + let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") }; + assert_eq!(report["page"], 1, "the page defaults to the first"); + assert_eq!(report["warnings"], json!([FALLBACK])); + let clean = open(&mut a, "clean.pdf"); + assert_eq!(a.call("render_preview", &json!({ "doc": clean })).unwrap().len(), 1, "a clean page gives the PNG alone"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_clean_page_renders_to_the_png_alone() { + let (dir, mut a) = setup("clean"); + let fallback = open(&mut a, "fallback.pdf"); + let clean = open(&mut a, "clean.pdf"); + // The warning of one render doesn't carry over to the next, nor to another document. + a.call("page_render", &json!({ "doc": fallback, "page": 1, "dpi": 72 })).unwrap(); + let out = a.call("page_render", &json!({ "doc": clean, "page": 1, "dpi": 72 })).unwrap(); + assert_eq!(out.len(), 1, "no warnings, no extra part: {out:?}"); + assert!(matches!(out[0], Content::Png { .. })); + let again = a.call("page_render", &json!({ "doc": fallback, "page": 1, "dpi": 72 })).unwrap(); + assert_eq!(again.len(), 2, "a cached renderer still reports the page's warning: {again:?}"); + 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] +fn mcp_page_render_carries_the_warnings_as_structured_content() { + use pdfcraft_automation::mcp::McpServer; + let (dir, a) = setup("mcp"); + let mut s = McpServer::new(a); + let mut rpc = |id: u64, method: &str, params: Value| -> Value { + let line = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string(); + serde_json::from_str(&s.handle_line(&line).expect("a reply")).unwrap() + }; + rpc(1, "initialize", json!({})); + rpc(2, "tools/call", json!({ "name": "doc_open", "arguments": { "path": "fallback.pdf" } })); + let reply = rpc(3, "tools/call", json!({ "name": "page_render", "arguments": { "doc": 1, "page": 1 } })); + let result = &reply["result"]; + assert_eq!(result["isError"], false, "{reply}"); + assert_eq!(result["content"][0]["type"], "image"); + assert_eq!(result["content"][1]["type"], "text"); + assert_eq!(result["structuredContent"]["warnings"], json!([FALLBACK])); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/cos/README.md b/crates/cos/README.md index ab382219d5..2cf3342954 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -24,6 +24,9 @@ let obj = doc.get(ObjRef { num: 12, gen: 0 }); // load (and cache): Arc, resolving first let data = stream.decoded()?; // filters applied on demand +let data = doc.with_direct_parms(&stream).decoded()?; // /DecodeParms given by reference too +let px = doc.decoded_image(&stream)?; // an image's samples, a CCITT fax image's too +let fax = doc.fax_params(&stream); // a CCITT fax image's /DecodeParms, Table 11 defaults doc.set(r, Object::Dict(d)); // edit: lands in the overlay let r = doc.add(obj); // append a new object @@ -87,5 +90,6 @@ recognisable in a diff and byte-stable when re-serialized. - Linearized output, Fast Web View (`core.linearization`, M11). - Structural validation against the Arlington model (`core.arlington-validation`), which will live in the reserved `arlington` crate. -- Image codecs. `pdfcraft-filters` carries the non-image filters; DCT, JPX, JBIG2 and CCITT are - still decoded through the bootstrap renderer (`core.image-filters`, M2.6). +- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax decoding + (`Document::decoded_image`); DCT, JPX and JBIG2 are still decoded through the bootstrap renderer + (`core.image-filters`, M2.6). diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 0fac1591a9..0d9b84b749 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -5,11 +5,12 @@ //! Edits never touch the original bytes; the writer appends them (incremental save) or rewrites //! the file (full save). +use std::borrow::Cow; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, Mutex, Weak}; use crate::CosError; -use crate::object::{Dict, ObjRef, Object}; +use crate::object::{Dict, MAX_DECODED, ObjRef, Object, Stream}; use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; mod objects; diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 8744a3ea3e..4f05d03ae0 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,7 +1,7 @@ use super::reader::find; use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; use super::xref::{Revision, XrefEntry}; -use super::{Arc, CosError, Dict, Document, Lexer, ObjRef, Object, is_whitespace, parse_indirect_shared}; +use super::{Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared}; impl Document { pub fn bytes(&self) -> &Arc> { @@ -158,11 +158,148 @@ impl Document { } } + /// `s` with the `/DecodeParms` its filters use made direct, so [`Stream::decoded`] reads them: + /// an indirect dictionary resolved, and of an array (itself perhaps indirect), the entries at + /// the filters' places (`/Crypt` counts, ISO 32000-2 §7.4.1) resolved, the rest dropped. Of + /// each dictionary only the entries the decoders read are kept, numbers and flags given by + /// reference (`/Predictor 7 0 R`) resolved; `/JBIG2Globals` stays a reference. A `/Crypt` + /// entry, which no decoder reads ([`Stream::filters`] leaves it out), is null. `s` itself + /// when none of those is indirect, or when the other filters are more than any decoder takes + /// ([`pdfcraft_filters::MAX_CHAIN`]). Only small values are copied, so a file can't have a + /// large object copied once for each key and filter. + pub fn with_direct_parms<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> { + let Some(p) = s.dict.get(b"DecodeParms") else { return Cow::Borrowed(s) }; + let crypt = |f: &Object| matches!(f.as_name(), Some(b"Crypt")); + let filters: Vec = match s.dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().map(crypt).collect(), + Some(f) => vec![crypt(f)], + None => Vec::new(), + }; + if filters.iter().filter(|c| !**c).count() > pdfcraft_filters::MAX_CHAIN { + return Cow::Borrowed(s); + } + let mut changed = matches!(p, Object::Ref(_)); + // Its numbers and flags kept, resolved; anything else is no filter's parameters (null). + let one = |e: &Object, changed: &mut bool| -> Object { + *changed |= matches!(e, Object::Ref(_)); + let Object::Dict(d) = &*self.held(e) else { return Object::Null }; + let mut made = Dict::new(); + for key in DECODE_PARMS_KEYS { + let Some(v) = d.get(key) else { continue }; + *changed |= matches!(v, Object::Ref(_)); + let v = self.held(v); + if matches!(*v, Object::Int(_) | Object::Real(_) | Object::Bool(_)) { + made.set(key.to_vec(), (*v).clone()); + } + } + if let Some(g @ Object::Ref(_)) = d.get(b"JBIG2Globals") { + made.set(b"JBIG2Globals".to_vec(), g.clone()); + } + Object::Dict(made) + }; + let made = match &*self.held(p) { + Object::Array(a) => { + Object::Array(a.iter().zip(&filters).map(|(e, crypt)| if *crypt { Object::Null } else { one(e, &mut changed) }).collect()) + } + _ => one(p, &mut changed), + }; + if !changed { + return Cow::Borrowed(s); + } + let mut s = s.clone(); + s.dict.set(b"DecodeParms".to_vec(), made); + Cow::Owned(s) + } + + /// `o`, or the object it refers to, without copying either. + fn held<'o>(&self, o: &'o Object) -> Held<'o> { + match o { + Object::Ref(_) => Held::Resolved(self.resolve(o)), + o => Held::Direct(o), + } + } + /// Resolve and return a dictionary (a stream's dictionary counts). pub fn dict(&self, o: &Object) -> Option { self.resolve(o).as_dict().cloned() } + /// The CCITT parameters of image `s` when its last filter is `CCITTFaxDecode`: its + /// `/DecodeParms` (resolved; the dictionary of a lone filter, or its entry in an array), with the + /// defaults of ISO 32000-2 Table 11 for what that leaves out. `None` for another image, and for + /// one whose `/Filter` isn't a name or an array of names. + pub fn fax_params(&self, s: &Stream) -> Option { + let chain = s.readable_filters().ok()?; + if !matches!(chain.last(), Some((pdfcraft_filters::Filter::CcittFax, _))) { + return None; + } + // Its place in /Filter, /Crypt included, pairs it with its /DecodeParms entry (§7.4.1): the + // last filter but a /Crypt, which `filters` leaves out wherever it stands. + let index = match s.dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().rposition(|f| f.as_name() != Some(b"Crypt".as_slice())).unwrap_or(0), + _ => 0, + }; + let parms = match s.dict.get(b"DecodeParms").map(|p| self.resolve(p)).as_deref() { + Some(Object::Array(a)) => a.get(index).and_then(|p| self.dict(p)), + Some(p) if index == 0 => p.as_dict().cloned(), + _ => None, + } + .unwrap_or_default(); + let int = |key: &[u8], default: i64| parms.get(key).and_then(|v| self.resolve(v).as_int()).unwrap_or(default); + let flag = |key: &[u8], default: bool| match parms.get(key).map(|v| self.resolve(v)).as_deref() { + Some(Object::Bool(b)) => *b, + _ => default, + }; + let d = pdfcraft_filters::CcittParams::default(); + Some(pdfcraft_filters::CcittParams { + k: int(b"K", d.k), + columns: int(b"Columns", d.columns), + rows: int(b"Rows", d.rows), + end_of_line: flag(b"EndOfLine", d.end_of_line), + encoded_byte_align: flag(b"EncodedByteAlign", d.encoded_byte_align), + end_of_block: flag(b"EndOfBlock", d.end_of_block), + black_is_1: flag(b"BlackIs1", d.black_is_1), + }) + } + + /// The samples of image `s`, decoded: through its filters ([`Stream::decoded`]), or, for a + /// CCITT fax image (its last filter), through the filters before it and then + /// [`pdfcraft_filters::decode_ccitt`] with its [`Document::fax_params`], as `CCITTFaxDecode` + /// gives them (ISO 32000-2 §7.4.6). Parameters given by reference count, for every filter + /// ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its `/Width`. Up to + /// its `/Height` rows are decoded, whatever `/Rows` says, within [`MAX_DECODED`] bytes; damaged + /// data gives fewer, so a caller checks it has the rows it needs. + pub fn decoded_image(&self, s: &Stream) -> Result, CosError> { + self.decoded_image_within(s, MAX_DECODED) + } + + /// [`Document::decoded_image`] for an image whose decoding has a tighter bound than + /// [`MAX_DECODED`]: no stage, the filters before a fax decoder included, may produce more than + /// `max` bytes. + pub fn decoded_image_within(&self, s: &Stream, max: usize) -> Result, CosError> { + let max = max.min(MAX_DECODED); + let s = &*self.with_direct_parms(s); + let Some(p) = self.fax_params(s) else { return s.decoded_within(max) }; + // As many rows as the image has: a /Rows below /Height would cut the picture short, and + // rows past /Height aren't drawn. + let p = pdfcraft_filters::CcittParams { rows: 0, ..p }; + if s.dict.int(b"Width") != Some(p.columns) { + return Err(CosError::Filter("a fax image's /Columns aren't its /Width".into())); + } + let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()).unwrap_or(0); + let stride = usize::try_from(p.columns).unwrap_or(usize::MAX).div_ceil(8).max(1); + let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height); + // The filters before it, such as ASCII85Decode, are undone first. + let chain = s.readable_filters().map_err(|e| CosError::Filter(e.to_string()))?; + let data = match chain.split_last() { + Some((_, before)) if !before.is_empty() => { + Cow::Owned(pdfcraft_filters::decode_tolerant(before, &s.raw, max).map_err(|e| CosError::Filter(e.to_string()))?.0) + } + _ => Cow::Borrowed(&s.raw[..]), + }; + pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(|e| CosError::Filter(e.to_string())) + } + pub fn try_get(&self, num: u32) -> Result, CosError> { match self.overlay.get(&num) { Some(Slot::Set(_, o)) => return Ok(o.clone()), @@ -570,3 +707,40 @@ impl Document { matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages")) } } + +/// 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] = [ + b"Predictor", + b"Colors", + b"BitsPerComponent", + b"Columns", + b"EarlyChange", + b"K", + b"Rows", + b"EndOfLine", + b"EncodedByteAlign", + b"EndOfBlock", + b"BlackIs1", + b"DamagedRowsBeforeError", + b"ColorTransform", +]; + +/// An object read in place, or the object a reference names as the document holds it: either way +/// nothing is copied ([`Document::held`]). +enum Held<'o> { + Direct(&'o Object), + Resolved(Arc), +} + +impl std::ops::Deref for Held<'_> { + type Target = Object; + + fn deref(&self) -> &Object { + match self { + Held::Direct(o) => o, + Held::Resolved(o) => o, + } + } +} diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index d41d564e33..57d697ed66 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -1,4 +1,4 @@ -use super::reader::{find, generated_id}; +use super::reader::find; use super::state::Credentials; use super::xref::XrefIndex; use super::{Arc, BTreeMap, CosError, Dict, Document, ObjRef, Object}; @@ -271,8 +271,7 @@ impl Document { let id0 = match self.trailer.get(b"ID") { Some(Object::Array(a)) if a.len() == 2 => a[0].as_string().map(|s| s.bytes.clone()).unwrap_or_default(), _ => { - let mut id = generated_id(¶ms.seed); - id.truncate(16); + let id = pdfcraft_crypt::file_id(¶ms.seed); let s = Object::String(crate::PdfString { bytes: id.clone(), hex: true }); self.trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s])); id diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs index 66129c1423..d21bf9403f 100644 --- a/crates/cos/src/document/reader.rs +++ b/crates/cos/src/document/reader.rs @@ -818,18 +818,6 @@ pub(super) fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option { (from..=hay.len() - needle.len()).rev().find(|&i| &hay[i..i + needle.len()] == needle) } -/// A file identifier derived from entropy supplied by the caller. -pub(super) fn generated_id(seed: &[u8; 32]) -> Vec { - use std::hash::{Hash, Hasher}; - let mut out = Vec::new(); - for i in 0..2u8 { - let mut h = std::collections::hash_map::DefaultHasher::new(); - (seed, i, b"pdfcraft id").hash(&mut h); - out.extend_from_slice(&h.finish().to_be_bytes()); - } - out -} - /// The object number a classic xref subsection really starts at. A common producer bug heads the /// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so /// every row would be read one number too high and object 1 as free. Such a subsection is read diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index bf22f23d76..f5e36f9c83 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -1,5 +1,210 @@ use super::*; +/// /DecodeParms given by reference, whole, as array entries or as the numbers inside, are read +/// once made direct: here a Flate predictor, which the stream alone doesn't see. Only the entries the filters use are +/// resolved, and nothing is for a chain longer than a decoder takes. +#[test] +fn decode_parms_given_by_reference_are_read_once_direct() { + let rows = [1u8, 2, 3, 4, 5, 6, 7, 8]; + let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 4, early_change: 1 }; + let coded = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &rows).unwrap(); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(4)); + let mut doc = Document::new_empty(); + let r = doc.add(Object::Dict(parms.clone())); + let array = doc.add(Object::Array(vec![Object::Ref(r)])); + let stream = |filters: usize, p: Object| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"); filters])); + d.set(b"DecodeParms".to_vec(), p); + Stream::from_raw(d, coded.clone()) + }; + for p in [Object::Ref(r), Object::Array(vec![Object::Ref(r)]), Object::Ref(array)] { + let s = stream(1, p.clone()); + assert_ne!(s.decoded().unwrap(), rows, "{p:?} read as it is"); + assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "{p:?}"); + } + // A number inside given by reference, which `Dict::int` doesn't follow: in the dictionary + // itself, and in an array's entry. + let twelve = doc.add(Object::Int(12)); + let mut inner = parms.clone(); + inner.set(b"Predictor".to_vec(), Object::Ref(twelve)); + for p in [Object::Dict(inner.clone()), Object::Array(vec![Object::Dict(inner)])] { + let s = stream(1, p.clone()); + assert_ne!(s.decoded().unwrap(), rows, "{p:?} read as it is"); + assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "{p:?}"); + } + // Only numbers and flags are copied: a key naming a big array, or an entry that is no + // dictionary, is left out (a file could name one big object from every key and filter). + let big = doc.add(Object::Array(vec![Object::Int(1); 100_000])); + let mut hostile = Dict::new(); + hostile.set(b"Predictor".to_vec(), Object::Ref(big)); + hostile.set(b"Columns".to_vec(), Object::Ref(big)); + hostile.set(b"Colors".to_vec(), Object::Ref(twelve)); + let entry = doc.add(Object::Dict(hostile)); + let two = stream(2, Object::Array(vec![Object::Ref(entry), Object::Ref(big)])); + let made = doc.with_direct_parms(&two); + let made = made.dict.get(b"DecodeParms").and_then(Object::as_array).unwrap(); + let first = made[0].as_dict().unwrap(); + assert_eq!((first.contains(b"Predictor"), first.contains(b"Columns"), first.int(b"Colors")), (false, false, Some(12))); + assert!(matches!(made[1], Object::Null), "{:?}", made[1]); + let direct = stream(1, Object::Dict(parms)); + assert!(matches!(doc.with_direct_parms(&direct), Cow::Borrowed(_)), "nothing to resolve"); + assert_eq!(direct.decoded().unwrap(), rows); + // Entries past the filters' are left out, unresolved; so is everything past the chain limit. + let many = stream(1, Object::Array(vec![Object::Ref(r); 1000])); + let made = doc.with_direct_parms(&many); + assert_eq!(made.dict.get(b"DecodeParms").and_then(Object::as_array).map(Vec::len), Some(1)); + let long = stream(pdfcraft_filters::MAX_CHAIN + 1, Object::Array(vec![Object::Ref(r); 9])); + assert!(matches!(doc.with_direct_parms(&long), Cow::Borrowed(_))); + // A chain as long as a decoder takes is resolved, and a `/Crypt` in it doesn't count, as + // the decoders leave it out: ASCIIHex as often as the limit allows, then the predictor's + // Flate, its parameters by reference. + let hex = |b: &[u8]| b.iter().flat_map(|x| format!("{x:02X}").into_bytes()).chain(*b">").collect::>(); + let wrapped = (1..pdfcraft_filters::MAX_CHAIN).fold(coded.clone(), |data, _| hex(&data)); + for crypt in [false, true] { + let mut names = vec![Object::name("ASCIIHexDecode"); pdfcraft_filters::MAX_CHAIN - 1]; + names.push(Object::name("FlateDecode")); + if crypt { + names.insert(0, Object::name("Crypt")); + } + let mut entries = vec![Object::Null; names.len() - 1]; + entries.push(Object::Ref(r)); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::Array(names)); + d.set(b"DecodeParms".to_vec(), Object::Array(entries)); + let s = Stream::from_raw(d, wrapped.clone()); + assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "with /Crypt: {crypt}"); + } +} + +/// A CCITT fax image reads through its /DecodeParms, given directly, in an array or by +/// reference (its numbers too), with /BlackIs1, and behind other filters; one whose /Columns +/// aren't its /Width is refused, /Height rows are decoded whatever /Rows says, and other images +/// decode through their filters. +#[test] +fn fax_images_decode_with_their_parameters() { + // 10 × 3: the first five samples of each row white, the rest black. + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_g4(&bits, 10, 3).unwrap(); + let mut doc = Document::new_empty(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(10)); + let indirect = doc.add(Object::Dict(parms.clone())); + let image = |doc: &Document, parms: Option, width: i64, height: i64| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(width)); + d.set(b"Height".to_vec(), Object::Int(height)); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + if let Some(p) = parms { + d.set(b"DecodeParms".to_vec(), p); + } + doc.decoded_image(&Stream::from_raw(d, coded.clone())) + }; + for p in [ + Object::Dict(parms.clone()), + Object::Array(vec![Object::Dict(parms.clone())]), + Object::Ref(indirect), + Object::Array(vec![Object::Ref(indirect)]), + ] { + assert_eq!(image(&doc, Some(p.clone()), 10, 3).unwrap(), bits, "{p:?}"); + } + let mut inverted = parms.clone(); + inverted.set(b"BlackIs1".to_vec(), Object::Bool(true)); + assert_eq!( + image(&doc, Some(Object::Dict(inverted)), 10, 3).unwrap(), + [0b0000_0111, 0b1100_0000, 0b0000_0111, 0b1100_0000, 0b0000_0111, 0b1100_0000] + ); + assert_eq!(image(&doc, Some(Object::Dict(parms.clone())), 10, 2).unwrap(), bits[..4], "at most /Height rows"); + // Within a bound: no more rows than it holds. + let mut bounded = Dict::new(); + bounded.set(b"Width".to_vec(), Object::Int(10)); + bounded.set(b"Height".to_vec(), Object::Int(3)); + bounded.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + bounded.set(b"DecodeParms".to_vec(), Object::Dict(parms.clone())); + assert_eq!(doc.decoded_image_within(&Stream::from_raw(bounded, coded.clone()), 5).unwrap(), bits[..4], "two rows of two bytes in five"); + let mut by_reference = Dict::new(); + by_reference.set(b"K".to_vec(), Object::Ref(doc.add(Object::Int(-1)))); + by_reference.set(b"Columns".to_vec(), Object::Ref(doc.add(Object::Int(10)))); + assert_eq!(image(&doc, Some(Object::Dict(by_reference)), 10, 3).unwrap(), bits, "/K and /Columns by reference"); + let mut understated = parms.clone(); + understated.set(b"Rows".to_vec(), Object::Int(1)); + assert_eq!(image(&doc, Some(Object::Dict(understated.clone())), 10, 3).unwrap(), bits, "/Height rows, whatever /Rows says"); + // The parameters as the file gives them, /Rows included, and Table 11's defaults. + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(understated)); + let p = doc.fax_params(&Stream::from_raw(d.clone(), Vec::new())).unwrap(); + assert_eq!((p.k, p.columns, p.rows, p.end_of_block, p.black_is_1), (-1, 10, 1, true, false)); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + assert!(doc.fax_params(&Stream::from_raw(d, Vec::new())).is_none(), "not a fax image"); + assert!(matches!(image(&doc, Some(Object::Dict(parms.clone())), 12, 3), Err(CosError::Filter(_))), "/Columns other than /Width"); + assert!(matches!(image(&doc, None, 10, 3), Err(CosError::Filter(_))), "the default 1728 /Columns"); + let mut plain = Dict::new(); + plain.set(b"Filter".to_vec(), Object::name("FlateDecode")); + assert_eq!(doc.decoded_image(&Stream::from_raw(plain, pdfcraft_filters::encode_flate(&bits))).unwrap(), bits); + + // Behind other filters, its parameters at its own place in the array (/Crypt counts). + let behind = |filters: &[Object], parms: &[Object], data: Vec| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(3)); + d.set(b"Filter".to_vec(), Object::Array(filters.to_vec())); + d.set(b"DecodeParms".to_vec(), Object::Array(parms.to_vec())); + doc.decoded_image(&Stream::from_raw(d, data)) + }; + let (fax, null, own) = (Object::name("CCITTFaxDecode"), Object::Null, Object::Dict(parms.clone())); + let hex = coded.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes(); + let flated = pdfcraft_filters::encode_flate(&coded); + let ahx = [Object::name("AHx"), fax.clone()]; + assert_eq!(behind(&ahx, &[null.clone(), Object::Ref(indirect)], hex.clone()).unwrap(), bits, "behind ASCIIHexDecode"); + assert_eq!( + behind(&[Object::name("FlateDecode"), Object::name("CCF")], &[null.clone(), own.clone()], flated.clone()).unwrap(), + bits, + "behind Flate" + ); + assert_eq!(behind(&[Object::name("Crypt"), fax.clone()], &[null.clone(), own.clone()], coded.clone()).unwrap(), bits, "after /Crypt"); + // A /Crypt belongs first (§7.4.10); one after the fax filter leaves it its own entry. + assert_eq!(behind(&[fax.clone(), Object::name("Crypt")], &[own.clone(), null.clone()], coded.clone()).unwrap(), bits, "before /Crypt"); + assert!(behind(&ahx, &[own.clone(), null.clone()], hex).is_err(), "the first entry is the other filter's"); + assert!(behind(&[fax.clone(), Object::name("FlateDecode")], &[own.clone(), null.clone()], flated).is_err(), "CCITT, then Flate"); + assert!(behind(&[Object::Int(5), fax], &[null, own], coded.clone()).is_err(), "a /Filter entry that isn't a name"); +} + +#[test] +fn a_missing_file_id_is_derived_from_the_encryption_seed() { + let mut doc = Document::new_empty(); + let seed = [9u8; 32]; + doc.set_encryption(&pdfcraft_crypt::NewEncryption { + algorithm: pdfcraft_crypt::Algorithm::Aes256, + user_password: "user", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed, + }) + .unwrap(); + let id = doc.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone()); + assert_eq!(id.as_deref(), Some(pdfcraft_crypt::file_id(&seed).as_slice())); + + let mut kept = Document::new_empty(); + let fixed = Object::String(crate::PdfString { bytes: b"0123456789abcdef".to_vec(), hex: true }); + kept.trailer_mut().set(b"ID".to_vec(), Object::Array(vec![fixed.clone(), fixed])); + kept.set_encryption(&pdfcraft_crypt::NewEncryption { + algorithm: pdfcraft_crypt::Algorithm::Aes256, + user_password: "user", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed, + }) + .unwrap(); + let still = kept.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone()); + assert_eq!(still.as_deref(), Some(b"0123456789abcdef".as_slice())); +} + #[test] fn a_read_state_matches_a_clone_and_no_changed_document() { let mut doc = Document::new_empty(); diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index c9b2ff9a36..17ee7844cf 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -261,7 +261,7 @@ impl Stream { /// [`Stream::filters`], or an error when `/Filter` is present but is not a name or an /// array of names (an indirect reference, say): the data is not unfiltered, so it must not /// read as decoded. - fn readable_filters(&self) -> Result, pdfcraft_filters::FilterError> { + pub(crate) fn readable_filters(&self) -> Result, pdfcraft_filters::FilterError> { let readable = match self.dict.get(b"Filter") { None | Some(Object::Null | Object::Name(_)) => true, Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some()), diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index 12daad621c..3648c3be00 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } +pdfcraft-filters = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-fonts = { workspace = true } # For `image::bmp_with_pixel_offset`, shared with XFA pictures. @@ -24,5 +25,7 @@ thiserror = { workspace = true } encoding_rs = "0.8" [dev-dependencies] +# An independent CCITT decoder, to check Create PDF's Group 4 images. +hayro-ccitt.workspace = true image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] } hayro-syntax = { workspace = true } diff --git a/crates/create/README.md b/crates/create/README.md index d1561a6333..a9aef83493 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -6,7 +6,9 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: - `from_images(&[(name, bytes)])`: one page per image, sized from the image's resolution (PNG `pHYs`, JPEG JFIF density; 72 dpi when absent). JPEG data is embedded as is (`/DCTDecode`, grey, RGB or Adobe-inverted CMYK); PNG is decoded and stored with Flate, - with transparency as a soft mask. An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG + with transparency as a soft mask. A picture of only pure black and white (a 1-bit PNG or TIFF, + eight-bit grey, a two-colour palette, RGB without a profile) is stored at one bit per pixel, as + CCITT Group 4 or Flate, whichever is smaller as stored (`pdfcraft_filters::encode_g4`). An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG `iCCP`) that matches the image's colour space is kept as an `/ICCBased` colour space with the device space as `/Alternate`; images with the same profile share one profile object. JPEG metadata scanning skips marker fill bytes and reads application segments after the @@ -31,3 +33,9 @@ Soft-mask Decode intervals map samples to opacity before the mask is resized, in The existing 16-bit path still uses each sample's high byte. JPEG image Decode handling remains limited. ICC transforms and calibrated color conversion remain outside this export path. +CCITT fax images (Group 3 and 4) are decoded for export through `pdfcraft_cos::Document::decoded_image` with their +`/DecodeParms` (`/Columns` has to be the image's width; up to `/Height` rows, whatever `/Rows` says), behind other filters such as +`ASCII85Decode` too. Byte-aligned Group 3 rows are read by their end-of-line codes, whether the fill starts each code on a byte or +ends it on one, when the data starts with one (T.4 sends one before the first row), and on bytes otherwise, whatever `/EndOfLine` +says; data that comes up short is reported. Data with codes but none before the first row is read on bytes, and is reported or, +at `/K 0`, may come out wrong. JPEG 2000 and JBIG2 images are still reported, not exported. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 7828952edc..e62eae31d2 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -1,7 +1,7 @@ //! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages //! use, as files. JPEG images are written unchanged; other images, and JPEG images with //! transparency or with CMYK samples a viewer would read inverted, are decoded and written as PNG with their soft mask, stencil mask or colour -//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, CCITT, separations) are +//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, separations) are //! reported, never silently left out. use std::collections::HashSet; @@ -109,7 +109,6 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> match f.last().map(Vec::as_slice) { Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()), Some(b"JBIG2Decode") => return Err("JBIG2 images can't be exported yet".into()), - Some(b"CCITTFaxDecode" | b"CCF") => return Err("CCITT fax images can't be exported yet".into()), Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()), _ => {} } @@ -148,7 +147,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> space = decoded; (samples, 8) } else { - let data = s.decoded().map_err(|e| e.to_string())?; + let data = doc.decoded_image(s).map_err(|e| e.to_string())?; let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) }; let bpc = match bpc { 1 => 1, @@ -403,9 +402,11 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { }; let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() { Some(Object::String(s)) => s.bytes.clone(), - Some(Object::Stream(t)) => { - t.decoded_within(MAX_LOOKUP).map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())? - } + // Through its parameters given by reference too, as the image's samples. + Some(Object::Stream(t)) => doc + .with_direct_parms(t) + .decoded_within(MAX_LOOKUP) + .map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?, _ => return Err("an indexed colour space without a lookup table".into()), }; Ok(Space::Indexed(Box::new(base), hival, table)) @@ -466,7 +467,7 @@ fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option Option> { let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?; let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true))); @@ -476,7 +477,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> let img = image::load_from_memory_with_format(&m.raw, image::ImageFormat::Jpeg).ok()?.into_luma8(); (img.width() as usize == mw && img.height() as usize == mh).then(|| img.into_raw())? } - Some(b"DCTDecode" | b"DCT" | b"JPXDecode" | b"JBIG2Decode" | b"CCITTFaxDecode" | b"CCF") => return None, + Some(b"DCTDecode" | b"DCT" | b"JPXDecode" | b"JBIG2Decode") => return None, _ => { let bpc = if stencil { 1 } else { m.dict.int(b"BitsPerComponent").unwrap_or(8) }; let bpc = match bpc { @@ -488,7 +489,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> _ => return None, }; let max = (1u32 << bpc.min(8)) - 1; - unpack(&m.decoded().ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() + unpack(&doc.decoded_image(m).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() } }; if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) { diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index a6fb28b3d6..36b52d4211 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -2,7 +2,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, serialize}; mod extract; mod upright_image; @@ -108,9 +108,42 @@ fn jpeg_icc(mut chunks: Vec<(u8, u8, &[u8])>) -> Option> { complete.then(|| chunks.iter().flat_map(|c| c.2.iter().copied()).collect()) } +/// The samples of a black-and-white image packed one bit per pixel (1 for white, each row +/// starting on a byte): eight-bit grey (`n` 1) or RGB (`n` 3) whose every pixel is pure black or +/// pure white, or one-bit grey, already packed (a bilevel TIFF). `None` for anything else, found +/// at its first other pixel. +fn bilevel(data: &[u8], (w, h): (u32, u32), n: usize, bits: Option) -> Option> { + let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + let stride = w.div_ceil(8); + let size = stride.checked_mul(h)?; + match (bits, n) { + (Some(1), 1) => data.get(..size).map(<[u8]>::to_vec), + (Some(8), 1 | 3) if w > 0 => { + let samples = data.get(..w.checked_mul(h)?.checked_mul(n)?)?; + let mut packed = vec![0u8; size]; + for (i, px) in samples.chunks_exact(n).enumerate() { + let white = match px { + [0] | [0, 0, 0] => false, + [255] | [255, 255, 255] => true, + _ => return None, + }; + if white && let Some(b) = packed.get_mut((i / w) * stride + (i % w) / 8) { + *b |= 0x80 >> (i % w % 8); + } + } + Some(packed) + } + _ => None, + } +} + /// Add `img` to `doc` as an image XObject. An ICC profile becomes an `/ICCBased` colour space /// (with the device space as `/Alternate`); `profiles` shares one object between images that /// carry the same profile. +/// +/// A black-and-white image is stored at one bit per pixel, as CCITT Group 4 when that is smaller +/// than Flate: losslessly, and several times smaller than the eight bits a scan saved as a 1-bit +/// PNG decodes to. RGB keeps its colour space when it has a colour profile. fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec, ObjRef)>) -> ObjRef { let mut d = img.dict; d.set(b"Type".to_vec(), Object::name("XObject")); @@ -118,12 +151,48 @@ fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec, Obj d.set(b"Width".to_vec(), Object::Int(img.px.0 as i64)); d.set(b"Height".to_vec(), Object::Int(img.px.1 as i64)); let device = d.name(b"ColorSpace").map(<[u8]>::to_vec); - let n = match device.as_deref() { + let mut n = match device.as_deref() { Some(b"DeviceGray") => 1, Some(b"DeviceRGB") => 3, Some(b"DeviceCMYK") => 4, _ => 0, }; + let (mut data, mut filtered) = (img.data, img.filtered); + let profiled_rgb = n == 3 && img.icc.is_some(); + if !filtered + && !profiled_rgb + && let Some(packed) = bilevel(&data, img.px, n, d.int(b"BitsPerComponent")) + { + if n == 3 { + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + n = 1; + } + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + // The smaller as stored, dictionary included: Group 4's parameters outweigh its saving on + // a tiny image. + let stored = |d: &Dict, data: &[u8]| { + let mut out = Vec::new(); + serialize(&Object::Dict(d.clone()), &mut out); + out.len().saturating_add(data.len()) + }; + let mut flate_dict = d.clone(); + flate_dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let flate = pdfcraft_filters::encode_flate(&packed); + (d, data) = match pdfcraft_filters::encode_g4(&packed, img.px.0, img.px.1) { + Ok(g4) => { + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(img.px.0))); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(img.px.1))); + let mut g4_dict = d.clone(); + g4_dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + g4_dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + if stored(&g4_dict, &g4) < stored(&flate_dict, &flate) { (g4_dict, g4) } else { (flate_dict, flate) } + } + Err(_) => (flate_dict, flate), + }; + filtered = true; + } if let (Some(icc), Some(device)) = (img.icc.and_then(|p| icc_for(p, n)), device) { let r = match profiles.iter().find(|(p, _)| *p == icc) { Some((_, r)) => *r, @@ -142,7 +211,7 @@ fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec, Obj let mr = doc.add(Object::Stream(Stream::flate(m, &alpha))); d.set(b"SMask".to_vec(), Object::Ref(mr)); } - let stream = if img.filtered { Stream::from_raw(d, img.data) } else { Stream::flate(d, &img.data) }; + let stream = if filtered { Stream::from_raw(d, data) } else { Stream::flate(d, &data) }; doc.add(Object::Stream(stream)) } diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 5cd34a0f1a..fc47e78adf 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -186,7 +186,7 @@ fn bmp_gif_and_multi_page_tiff_images() { let mut enc = image::codecs::gif::GifEncoder::new(&mut gif); enc.encode(img.as_raw(), 2, 2, image::ExtendedColorType::Rgba8).unwrap(); } - // TIFF: two pages, an 8-bit gray one at 144 dpi and a 1-bit one. + // TIFF: two 8-bit gray pages, one at 144 dpi and one of only black and white. let mut tif = std::io::Cursor::new(Vec::new()); { let mut enc = tiff::encoder::TiffEncoder::new(&mut tif).unwrap(); @@ -1295,6 +1295,48 @@ fn a_cyclic_indexed_colour_space_is_refused() { assert_eq!(out.skipped.len(), 1, "{:?}", out.skipped); } +/// A baseline TIFF of one uncompressed strip of 1-bit samples, `packed` rows each starting on a +/// byte, 0 for black (BlackIsZero; TIFF 6.0, section 3): the `tiff` crate writes no 1-bit images. +fn one_bit_tiff(w: u32, h: u32, packed: &[u8]) -> Vec { + // Tag, type (3 SHORT, 4 LONG), value; the strip follows the IFD. + let entries: [(u16, u16, u32); 8] = + [(256, 4, w), (257, 4, h), (258, 3, 1), (259, 3, 1), (262, 3, 1), (273, 4, 0), (278, 4, h), (279, 4, packed.len() as u32)]; + let strip = 8 + 2 + entries.len() as u32 * 12 + 4; + let mut out = b"II*\0".to_vec(); + out.extend(8u32.to_le_bytes()); + out.extend((entries.len() as u16).to_le_bytes()); + for (tag, kind, value) in entries { + let value = if tag == 273 { strip } else { value }; + out.extend(tag.to_le_bytes()); + out.extend(kind.to_le_bytes()); + out.extend(1u32.to_le_bytes()); + match kind { + 3 => out.extend((value as u16).to_le_bytes().into_iter().chain([0, 0])), + _ => out.extend(value.to_le_bytes()), + } + } + out.extend(0u32.to_le_bytes()); + out.extend_from_slice(packed); + out +} + +/// A bilevel TIFF (one bit a sample) stays one bit, stored as Group 4 when that is smaller, and +/// exports as its picture. +#[test] +fn a_one_bit_tiff_is_stored_as_group_4() { + let (w, h) = (1200u32, 240u32); + let bits = strokes(w, h); + let stride = (w as usize).div_ceil(8); + let picture: Vec = + (0..(w * h) as usize).map(|i| bits[i / w as usize * stride + i % w as usize / 8] & (0x80 >> (i % w as usize % 8)) != 0).collect(); + let doc = from_images(&[("scan.tif".into(), one_bit_tiff(w, h, &bits))]).unwrap(); + let im = image_of(&doc, 0); + assert_eq!((im.int(b"BitsPerComponent"), im.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..]))); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture); +} + /// A TIFF of `pages` 64×64 gray pages, then (with `unreadable`) one whose samples Create can't read. fn gray_tiff(pages: usize, unreadable: bool) -> Vec { let mut tif = std::io::Cursor::new(Vec::new()); @@ -1338,3 +1380,557 @@ fn a_tiny_gif_on_a_huge_screen_is_refused() { let doc = reopen(&from_images(&[("small.gif".into(), gif_on_screen(40, 30))]).unwrap()); assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 40.0, 30.0]); } + +/// A PNG of `w` × `h` samples `data` in `color` at `depth`, with a palette or an ICC profile. +fn png_of(w: u32, h: u32, color: png::ColorType, depth: png::BitDepth, data: &[u8], palette: Option>, icc: Option>) -> Vec { + let mut out = Vec::new(); + { + let mut info = png::Info::with_size(w, h); + info.color_type = color; + info.bit_depth = depth; + info.palette = palette.map(Into::into); + info.icc_profile = icc.map(Into::into); + let mut writer = png::Encoder::with_info(&mut out, info).unwrap().write_header().unwrap(); + writer.write_image_data(data).unwrap(); + } + out +} + +/// Rows of `w` one-bit samples (1 white), each on a new byte: lines of glyphs drawn from a few +/// shapes at uneven spacing, whose edges move a pixel now and then, as a scan of text has them. +/// (Clean, evenly spaced strokes favour Flate over Group 4.) +fn strokes(w: u32, h: u32) -> Vec { + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let (w, h) = (w as usize, h as usize); + for line in (4..h.saturating_sub(18)).step_by(24) { + let mut x = 8; + while x + 16 < w { + let wide = 5 + (next() % 4) as usize; + let shape = next() % 4; + let mut fill = |x0: usize, x1: usize, y0: usize, y1: usize| { + for y in line + y0..line + y1 { + let ragged = |r: u64| match r % 8 { + 0 => 0, + 1 => 2, + _ => 1, + }; + let (left, right) = ((x + x0 + 1).saturating_sub(ragged(next())), x + x1 + ragged(next())); + for xx in left..right.saturating_sub(1).min(w) { + data[y * stride + xx / 8] &= !(0x80 >> (xx % 8)); + } + } + }; + match shape { + 0 => fill(0, 2, 0, 14), + 1 => { + fill(0, wide, 4, 6); + fill(0, wide, 12, 14); + fill(0, 2, 4, 14); + fill(wide - 2, wide, 4, 14); + } + 2 => { + fill(0, 2, 4, 14); + fill(wide - 2, wide, 6, 14); + fill(0, wide - 1, 4, 6); + } + _ => { + fill(0, 2, 7, 14); + fill(0, 2, 2, 4); + } + } + x += wide + 2 + (next() % 2) as usize + if next() % 6 == 0 { 7 } else { 0 }; + } + } + data +} + +/// The one-bit samples (true for white) a Group 4 image stream decodes to, through an +/// independent decoder. +fn g4_rows(s: &Stream) -> Vec> { + struct Rows(Vec>, Vec, usize); + impl hayro_ccitt::Decoder for Rows { + fn push_pixel(&mut self, white: bool) { + self.1.push(white); + } + fn push_pixel_chunk(&mut self, white: bool, chunks: u32) { + self.1.extend(std::iter::repeat_n(white, chunks as usize * 8)); + } + fn next_line(&mut self) { + let mut row = std::mem::take(&mut self.1); + row.truncate(self.2); + self.0.push(row); + } + } + let (w, h) = (s.dict.int(b"Width").unwrap() as u32, s.dict.int(b"Height").unwrap() as u32); + let settings = hayro_ccitt::DecodeSettings { + columns: w, + rows: h, + end_of_block: true, + end_of_line: false, + rows_are_byte_aligned: false, + encoding: hayro_ccitt::EncodingMode::Group4, + invert_black: false, + }; + let mut out = Rows(Vec::new(), Vec::new(), w as usize); + hayro_ccitt::decode(&s.raw, &mut out, &mut hayro_ccitt::DecoderContext::new(settings)).unwrap(); + out.0 +} + +fn image_stream(doc: &Document, page: usize) -> Stream { + let p = &pages(doc)[page]; + let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + match &*doc.resolve(xo.get(b"Im0").unwrap()) { + Object::Stream(s) => s.clone(), + _ => panic!("not an image"), + } +} + +/// Black-and-white pictures are stored at one bit per pixel, as CCITT Group 4 when that is +/// smaller, whichever way the file holds them: a 1-bit PNG (which decodes to eight bits), eight-bit +/// grey of only black and white, a two-colour palette, black-and-white RGB. The bits are the +/// picture's. A grey profile stays; a picture with any other shade, colour, or an RGB profile is +/// stored as before. +#[test] +fn black_and_white_images_are_stored_at_one_bit() { + use png::{BitDepth, ColorType}; + let (w, h) = (1200u32, 240u32); + let bits = strokes(w, h); + let stride = (w as usize).div_ceil(8); + let white = |x: usize, y: usize| bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0; + let expected: Vec> = (0..h as usize).map(|y| (0..w as usize).map(|x| white(x, y)).collect()).collect(); + let grey: Vec = expected.iter().flatten().map(|&wh| if wh { 255 } else { 0 }).collect(); + let rgb: Vec = grey.iter().flat_map(|&v| [v, v, v]).collect(); + let pictures = [ + png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, None), + png_of(w, h, ColorType::Grayscale, BitDepth::Eight, &grey, None, None), + png_of(w, h, ColorType::Indexed, BitDepth::One, &bits.iter().map(|b| !b).collect::>(), Some(vec![255, 255, 255, 0, 0, 0]), None), + png_of(w, h, ColorType::Rgb, BitDepth::Eight, &rgb, None, None), + png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, Some(icc_profile(b"GRAY"))), + ]; + let files: Vec<(String, Vec)> = pictures.into_iter().enumerate().map(|(i, p)| (format!("{i}.png"), p)).collect(); + let doc = reopen(&from_images(&files).unwrap()); + for page in 0..5 { + let s = image_stream(&doc, page); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..])), "page {page}"); + assert_eq!(g4_rows(&s), expected, "page {page}"); + assert!(s.raw.len() < bits.len(), "page {page}: {} bytes, smaller than its one-bit samples", s.raw.len()); + } + assert_eq!(image_of(&doc, 3).name(b"ColorSpace"), Some(&b"DeviceGray"[..]), "black-and-white RGB becomes grey"); + assert_eq!(icc_of(&doc, &image_of(&doc, 4)).map(|(n, ..)| n), Some(1), "the grey profile stays"); + + // Another shade, colour, an RGB profile: as before. A tiny picture: one bit, but Flate. + let mut shaded = grey.clone(); + shaded[5] = 128; + let mut coloured = rgb.clone(); + coloured[0] = 200; + let others = [ + png_of(w, h, ColorType::Grayscale, BitDepth::Eight, &shaded, None, None), + png_of(w, h, ColorType::Rgb, BitDepth::Eight, &coloured, None, None), + png_of(w, h, ColorType::Rgb, BitDepth::Eight, &rgb, None, Some(icc_profile(b"RGB "))), + png_of(2, 2, ColorType::Grayscale, BitDepth::Eight, &[0, 255, 255, 0], None, None), + ]; + let files: Vec<(String, Vec)> = others.into_iter().enumerate().map(|(i, p)| (format!("o{i}.png"), p)).collect(); + let doc = reopen(&from_images(&files).unwrap()); + let kept: Vec<(Option, Option>)> = + (0..4).map(|p| (image_of(&doc, p).int(b"BitsPerComponent"), image_of(&doc, p).name(b"Filter").map(<[u8]>::to_vec))).collect(); + assert_eq!( + kept[..3], + [(Some(8), Some(b"FlateDecode".to_vec())), (Some(8), Some(b"FlateDecode".to_vec())), (Some(8), Some(b"FlateDecode".to_vec()))] + ); + assert_eq!(kept[3], (Some(1), Some(b"FlateDecode".to_vec())), "Group 4 isn't smaller for 2 × 2"); +} + +/// The pixels of an exported PNG (RGB), as true for white. +fn exported_white(png_data: &[u8]) -> Vec { + let mut reader = png::Decoder::new(std::io::Cursor::new(png_data.to_vec())).read_info().unwrap(); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut buf).unwrap(); + assert_eq!(frame.color_type, png::ColorType::Rgb); + buf[..frame.buffer_size()].as_chunks::<3>().0.iter().map(|p| *p == [255, 255, 255]).collect() +} + +/// Export ▸ Images reads CCITT fax images: a page created from a 1-bit PNG (Group 4 now) exports as +/// the picture, as does the picture coded the other way round with /BlackIs1, the picture with its +/// filter and parameters in arrays, with /K and /Columns given by reference, with a /Rows below its +/// height, and behind another filter (its parameters by reference too). One whose /Columns aren't +/// its width is skipped, not misread. +#[test] +fn ccitt_fax_images_export_as_their_picture() { + use png::{BitDepth, ColorType}; + let (w, h) = (1200u32, 240u32); + let bits = strokes(w, h); + let stride = (w as usize).div_ceil(8); + let picture: Vec = + (0..h as usize).flat_map(|y| (0..w as usize).map(move |x| (y, x))).map(|(y, x)| bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect(); + let mut doc = from_images(&[("scan.png".into(), png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, None))]).unwrap(); + assert_eq!(image_of(&doc, 0).name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture); + + // The same picture coded with 1 for black, which /BlackIs1 says. + let r = { + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + xo.get(b"Im0").and_then(Object::as_ref).unwrap() + }; + let base = image_of(&doc, 0); + let with = |black_is_1: bool, columns: i64, data: Vec| { + let mut d = base.clone(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(columns)); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(h))); + parms.set(b"BlackIs1".to_vec(), Object::Bool(black_is_1)); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + d.remove(b"Length"); + Stream::from_raw(d, data) + }; + let inverted: Vec = bits.iter().map(|b| !b).collect(); + let stream = with(true, i64::from(w), pdfcraft_filters::encode_g4(&inverted, w, h).unwrap()); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_white(&out.images[0].data), picture, "BlackIs1"); + + let mut stream = with(false, i64::from(w), pdfcraft_filters::encode_g4(&bits, w, h).unwrap()); + let parms = stream.dict.get(b"DecodeParms").cloned().unwrap(); + stream.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![parms])); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("CCITTFaxDecode")])); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture, "in arrays"); + + let mut stream = with(false, i64::from(w), pdfcraft_filters::encode_g4(&bits, w, h).unwrap()); + let mut parms = stream.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + parms.set(b"K".to_vec(), Object::Ref(doc.add(Object::Int(-1)))); + parms.set(b"Columns".to_vec(), Object::Ref(doc.add(Object::Int(i64::from(w))))); + parms.set(b"Rows".to_vec(), Object::Int(1)); + stream.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture, "/K and /Columns by reference, /Rows 1"); + + // Behind another filter, with its parameters second in the array, as 7.4.1 pairs them. + let g4 = pdfcraft_filters::encode_g4(&bits, w, h).unwrap(); + let hex = g4.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes(); + for (outer, data) in [("ASCIIHexDecode", hex), ("FlateDecode", pdfcraft_filters::encode_flate(&g4))] { + let mut stream = with(false, i64::from(w), data); + let parms = stream.dict.get(b"DecodeParms").cloned().unwrap(); + stream.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, parms])); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name(outer), Object::name("CCITTFaxDecode")])); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{outer}: {:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture, "behind {outer}"); + } + // Behind Flate with a PNG predictor, whose parameters are given by reference. + let padded: Vec = g4.iter().copied().chain(std::iter::repeat_n(0, 16 - g4.len() % 16)).collect(); + let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 16, early_change: 1 }; + let predicted = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &padded).unwrap(); + let mut flate_parms = Dict::new(); + flate_parms.set(b"Predictor".to_vec(), Object::Int(12)); + flate_parms.set(b"Columns".to_vec(), Object::Int(16)); + let flate_parms = doc.add(Object::Dict(flate_parms)); + let mut stream = with(false, i64::from(w), predicted); + let parms = stream.dict.get(b"DecodeParms").cloned().unwrap(); + stream.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Ref(flate_parms), parms])); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")])); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture, "behind Flate with a predictor given by reference"); + // CCITT before another filter isn't a picture yet. + let mut stream = with(false, i64::from(w), pdfcraft_filters::encode_flate(&g4)); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("CCITTFaxDecode"), Object::name("FlateDecode")])); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty() && out.skipped.len() == 1, "CCITT, then Flate: skipped"); + + let stream = with(false, i64::from(w) + 8, pdfcraft_filters::encode_g4(&bits, w, h).unwrap()); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty() && out.skipped.len() == 1, "columns other than the width: skipped"); +} + +/// Group 3 data: rows of run lengths below 64, white first, coded one-dimensionally with T.4's +/// terminating codes (ITU-T T.4 Tables 2 and 3). Each row starts with an end-of-line code with +/// `eol`, then with `tag` a 1 bit (the next row one-dimensional, as `/K` above 0 has it). Fill bits +/// (0s) go where `fill` says. +fn group_3(rows: &[impl AsRef<[u32]>], eol: bool, tag: bool, fill: Fill) -> Vec { + // The codes for runs 0 to 63, in order. + const WHITE: &str = concat!( + "00110101 000111 0111 1000 1011 1100 1110 1111 10011 10100 00111 01000 001000 000011 110100 110101 101010 101011 ", + "0100111 0001100 0001000 0010111 0000011 0000100 0101000 0101011 0010011 0100100 0011000 00000010 00000011 00011010 ", + "00011011 00010010 00010011 00010100 00010101 00010110 00010111 00101000 00101001 00101010 00101011 00101100 00101101 ", + "00000100 00000101 00001010 00001011 01010010 01010011 01010100 01010101 00100100 00100101 01011000 01011001 01011010 ", + "01011011 01001010 01001011 00110010 00110011 00110100", + ); + const BLACK: &str = concat!( + "0000110111 010 11 10 011 0011 0010 00011 000101 000100 0000100 0000101 0000111 00000100 00000111 000011000 0000010111 ", + "0000011000 0000001000 00001100111 00001101000 00001101100 00000110111 00000101000 00000010111 00000011000 ", + "000011001010 000011001011 000011001100 000011001101 000001101000 000001101001 000001101010 000001101011 000011010010 ", + "000011010011 000011010100 000011010101 000011010110 000011010111 000001101100 000001101101 000011011010 000011011011 ", + "000001010100 000001010101 000001010110 000001010111 000001100100 000001100101 000001010010 000001010011 000000100100 ", + "000000110111 000000111000 000000100111 000000101000 000001011000 000001011001 000000101011 000000101100 000001011010 ", + "000001100110 000001100111", + ); + let code = |white: bool, run: u32| (if white { WHITE } else { BLACK }).split(' ').nth(run as usize).expect("a run below 64"); + let mut bits = String::new(); + for row in rows { + // The end-of-line code is 12 bits. + let ends_code = if fill == Fill::CodeEnd && eol { 12 } else { 0 }; + while fill != Fill::None && !(bits.len() + ends_code).is_multiple_of(8) { + bits.push('0'); + } + if eol { + bits.push_str("000000000001"); + } + if tag { + bits.push('1'); + } + row.as_ref().iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run))); + } + bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect() +} + +/// `count` text-like pictures `w` by `h` for [`group_3`], from a fixed seed: rows of random runs, +/// long white ones and short black ones, and a row may start black. +fn random_pictures(w: u32, h: u32, count: usize) -> Vec>> { + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut random = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n) as u32 + }; + let mut row = || { + let (mut runs, mut left) = (Vec::new(), w); + while left > 0 { + let run = match runs.len() % 2 { + 0 if runs.is_empty() => random(31), + 0 => 1 + random(30), + _ => 1 + random(5), + }; + runs.push(run.min(left)); + left -= run.min(left); + } + runs + }; + (0..count).map(|_| (0..h).map(|_| row()).collect()).collect() +} + +/// The picture `rows` of runs (white first) make, `w` by `h`, true where white. +fn white_of(rows: &[Vec], w: u32, h: u32) -> Vec { + let mut white = vec![false; (w * h) as usize]; + for (y, row) in rows.iter().enumerate() { + let mut x = y * w as usize; + for (i, run) in row.iter().enumerate() { + white[x..x + *run as usize].fill(i % 2 == 0); + x += *run as usize; + } + } + white +} + +/// Where [`group_3`] puts fill bits: none; so each row, its end-of-line code first, starts on a +/// byte; or so each end-of-line code ends on one (T.4's fill, as TIFF writes it). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Fill { + None, + RowStart, + CodeEnd, +} + +/// What Export ▸ Images claims for CCITT and for parameters given by reference, each checked +/// against the same picture stored another way: Group 3 one-dimensional data (`/K 0`) exports as +/// its picture; a CCITT soft mask and a CCITT stencil `/Mask` export as the same masks in Flate; +/// and a Flate image's PNG predictor, and an indexed image's lookup table stream with one, read +/// the same with their `/DecodeParms` given by reference as direct. +#[test] +fn group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture() { + use png::{BitDepth, ColorType}; + let (w, h) = (64u32, 16u32); + let rows: Vec<&[u32]> = (0..h).map(|y| if y % 2 == 0 { &[32u32, 32][..] } else { &[16, 16, 32][..] }).collect(); + let mut bits = vec![0u8; 8 * h as usize]; + for (y, runs) in rows.iter().enumerate() { + let mut x = 0; + for (i, run) in runs.iter().enumerate() { + if i % 2 == 0 { + (x..x + *run as usize).for_each(|xx| bits[y * 8 + xx / 8] |= 0x80 >> (xx % 8)); + } + x += *run as usize; + } + } + let picture: Vec = (0..(w * h) as usize).map(|i| bits[i / 8] & (0x80 >> (i % 8)) != 0).collect(); + let mut doc = from_images(&[("scan.png".into(), png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, None))]).unwrap(); + let r = { + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + xo.get(b"Im0").and_then(Object::as_ref).unwrap() + }; + let image = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d + }; + let fax = |k: i64| { + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(k)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + parms.set(b"Rows".to_vec(), Object::Int(h.into())); + [("Filter", Object::name("CCITTFaxDecode")), ("DecodeParms", Object::Dict(parms))] + }; + let grey = |bits: i64| [("BitsPerComponent", Object::Int(bits)), ("ColorSpace", Object::name("DeviceGray"))]; + + // One-dimensional (/K 0) plain, with end-of-line codes, with each row on a byte, and with + // both; mixed (/K 1, each row tagged one-dimensional) with end-of-line codes and without. + // Byte-aligned with end-of-line codes either way: each code starting on a byte, or ending on + // one (T.4's fill, as TIFF writes it). /EncodedByteAlign is given by reference once, and so is + // a /BlackIs1 that inverts the picture. + let inverted: Vec = picture.iter().map(|white| !white).collect(); + for (k, eol, fill, align_by_reference, black_is_1) in [ + (0, false, Fill::None, false, false), + (0, true, Fill::None, false, false), + (0, false, Fill::RowStart, true, false), + (0, true, Fill::RowStart, false, false), + (0, true, Fill::CodeEnd, false, false), + (1, true, Fill::None, false, false), + (1, false, Fill::None, false, false), + (1, true, Fill::CodeEnd, false, false), + (0, false, Fill::None, false, true), + ] { + let mut d = image(&[&grey(1)[..], &fax(k)].concat()); + let align = Object::Bool(fill != Fill::None); + let align = if align_by_reference { Object::Ref(doc.add(align)) } else { align }; + let inverts = Object::Ref(doc.add(Object::Bool(black_is_1))); + if let Some(Object::Dict(parms)) = d.get_mut(b"DecodeParms") { + parms.set(b"EndOfLine".to_vec(), Object::Bool(eol)); + parms.set(b"EncodedByteAlign".to_vec(), align); + parms.set(b"BlackIs1".to_vec(), inverts); + } + doc.set(r, Object::Stream(Stream::from_raw(d, group_3(&rows, eol, k > 0, fill)))); + let case = format!("/K {k}, EndOfLine {eol}, fill {fill:?}, BlackIs1 {black_is_1}"); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{case}: {:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), if black_is_1 { inverted.clone() } else { picture.clone() }, "{case}"); + } + // Byte-aligned rows against /EndOfLine, over several pictures, as how a misreading comes out + // depends on the picture: no codes (as TIFF's Modified Huffman has it), and /EndOfLine without + // them; codes, each starting on a byte or ending on one, /EndOfLine or not. Each exports as its + // picture, and a byte short is reported. The first picture is stripes, every row 32 white then + // 32 black; the others are text-like rows of random runs. + let image_with = |doc: &mut Document, k: i64, eol: bool, data: Vec| { + let mut d = image(&[&grey(1)[..], &fax(k)].concat()); + if let Some(Object::Dict(parms)) = d.get_mut(b"DecodeParms") { + parms.set(b"EndOfLine".to_vec(), Object::Bool(eol)); + parms.set(b"EncodedByteAlign".to_vec(), Object::Bool(true)); + } + doc.set(r, Object::Stream(Stream::from_raw(d, data))); + extract_images(doc, &[0], 0) + }; + let mut pictures = vec![vec![vec![32u32, 32]; h as usize]]; + pictures.extend(random_pictures(w, h, 8)); + for (n, runs) in pictures.iter().enumerate() { + let white = white_of(runs, w, h); + for k in [0, 1] { + for (codes, fill, eol) in [ + (false, Fill::RowStart, false), + (false, Fill::RowStart, true), + (true, Fill::RowStart, false), + (true, Fill::RowStart, true), + (true, Fill::CodeEnd, false), + (true, Fill::CodeEnd, true), + ] { + let out = image_with(&mut doc, k, eol, group_3(runs, codes, k > 0, fill)); + let case = format!("picture {n}, /K {k}, codes {codes}, fill {fill:?}, /EndOfLine {eol}"); + assert!(out.skipped.is_empty(), "{case}: {:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), white, "{case}"); + // A byte short, it is reported, not read the other way into other rows. + let mut short = group_3(runs, codes, k > 0, fill); + short.pop(); + let out = image_with(&mut doc, k, eol, short); + assert!(out.images.is_empty() && out.skipped.len() == 1, "{case}, a byte short: {:?}", out.skipped); + // At /K 1, codes with none before the first row (the first code and its fill are 2 + // bytes here) are read on bytes: the picture or reported, never another picture. + if k == 1 && fill == Fill::CodeEnd { + let out = image_with(&mut doc, k, eol, group_3(runs, codes, true, fill).split_off(2)); + assert!(out.images.iter().all(|i| exported_white(&i.data) == white), "{case}, no code first"); + } + } + } + } + // Group 4 rows on bytes keep their aligned read, /EndOfLine or not: here white rows, each the + // one code 1 (vertical, no change) and its fill. + for eol in [false, true] { + let out = image_with(&mut doc, -1, eol, vec![0x80; h as usize]); + assert!(out.skipped.is_empty(), "byte-aligned Group 4, /EndOfLine {eol}: {:?}", out.skipped); + assert!(exported_white(&out.images[0].data).iter().all(|white| *white), "byte-aligned Group 4, /EndOfLine {eol}"); + } + + // A grey picture under the same bits as a soft mask, then as a stencil mask: CCITT against Flate. + let g4 = pdfcraft_filters::encode_g4(&bits, w, h).unwrap(); + for (key, mask) in [("SMask", grey(1).to_vec()), ("Mask", vec![("ImageMask", Object::Bool(true))])] { + let mut exported = Vec::new(); + for coded in [true, false] { + let m = if coded { Stream::from_raw(image(&[&mask[..], &fax(-1)].concat()), g4.clone()) } else { Stream::flate(image(&mask), &bits) }; + let m = doc.add(Object::Stream(m)); + let d = image(&[&grey(8)[..], &[(key, Object::Ref(m))]].concat()); + doc.set(r, Object::Stream(Stream::flate(d, &vec![100u8; (w * h) as usize]))); + exported.push(exported_rgba(&doc)); + } + assert!(exported[0].1.chunks(4).any(|p| p[3] == 0), "{key}: masked somewhere"); + assert_eq!(exported[0], exported[1], "{key}: CCITT as Flate"); + } + + // A PNG predictor given by reference, for the samples and for a lookup table. + let predicted = |rows: &[u8], columns: i64| { + let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns, early_change: 1 }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(columns)); + (pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, rows).unwrap(), parms) + }; + let shades: Vec = (0..w * h).map(|i| (i * 7 % 256) as u8).collect(); + let (table, table_parms) = predicted(&[255, 0, 0, 0, 0, 255], 3); + let indices: Vec = (0..w * h).map(|i| (i % 3 % 2) as u8).collect(); + let mut exported = Vec::new(); + for by_reference in [false, true] { + let parms = |doc: &mut Document, p: &Dict| if by_reference { Object::Ref(doc.add(Object::Dict(p.clone()))) } else { Object::Dict(p.clone()) }; + let (samples, sample_parms) = predicted(&shades, w.into()); + let sample_parms = parms(&mut doc, &sample_parms); + let d = image(&[&grey(8)[..], &[("Filter", Object::name("FlateDecode")), ("DecodeParms", sample_parms)]].concat()); + doc.set(r, Object::Stream(Stream::from_raw(d, samples))); + let plain = exported_rgba(&doc); + + let mut lookup = Dict::new(); + lookup.set(b"Filter".to_vec(), Object::name("FlateDecode")); + lookup.set(b"DecodeParms".to_vec(), parms(&mut doc, &table_parms)); + let lookup = doc.add(Object::Stream(Stream::from_raw(lookup, table.clone()))); + let cs = Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), 1.into(), Object::Ref(lookup)]); + let d = image(&[("BitsPerComponent", Object::Int(8)), ("ColorSpace", cs)]); + doc.set(r, Object::Stream(Stream::flate(d, &indices))); + exported.push((plain, exported_rgba(&doc))); + } + assert_eq!(exported[0].0.1.chunks(4).map(|p| p[0]).collect::>(), shades, "the samples, direct"); + assert_eq!(exported[0].1.1.get(..8), Some(&[255, 0, 0, 255, 0, 0, 255, 255][..]), "red, then blue"); + assert_eq!(exported[1], exported[0], "by reference as direct"); +} diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 58e96e7d4f..ff92d9af66 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -634,12 +634,20 @@ fn expand(seed: &[u8; 32], label: &[u8], n: usize) -> Vec { h.update(label); h.update(counter.to_le_bytes()); out.extend_from_slice(&h.finalize()); - counter += 1; + counter = counter.saturating_add(1); } out.truncate(n); out } +/// Sixteen bytes of file identifier from `seed` (SHA-256 in counter mode, label `id`). +/// +/// `/ID` is written in the clear. Deriving it the same way as the file key keeps that public +/// value from being an unkeyed hash of the seed. +pub fn file_id(seed: &[u8; 32]) -> Vec { + expand(seed, b"id", 16) +} + /// Build the `/Encrypt` values and an authenticated (owner) handler for a new encryption. pub fn create(params: &NewEncryption, id0: &[u8]) -> Result { // Bits 7–8 and 13–32 must be 1; bits 1–2 must be 0 (§7.6.4.2). diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs index c812f9f7bd..6064176d78 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -4,6 +4,16 @@ fn hex(s: &str) -> Vec { (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() } +#[test] +fn file_id_is_a_one_way_16_byte_identifier() { + let seed = [7u8; 32]; + let id = file_id(&seed); + assert_eq!(id.len(), 16); + assert_eq!(id, file_id(&seed)); + assert_ne!(id, seed[..16]); + assert_ne!(id, file_id(&[8u8; 32])); +} + #[test] fn rc4_reference_vectors() { assert_eq!(rc4(b"Key", b"Plaintext"), hex("bbf316e8d940af0ad3")); diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 4eb2d6132b..89d9387989 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -15,6 +15,8 @@ pdfcraft-content = { workspace = true } pdfcraft-fonts = { workspace = true } pdfcraft-model = { workspace = true } thiserror = { workspace = true } +# A backdrop copy's tag records a hash of the operators before the fill it turned off (backdrop.rs). +sha2 = "0.11" kurbo = { workspace = true } # Display order of right-to-left lines in added text, and reading and writing order in Edit text; # already in the tree via the interface. diff --git a/crates/edit/src/apply.rs b/crates/edit/src/apply.rs index 427f51f943..ff570d5483 100644 --- a/crates/edit/src/apply.rs +++ b/crates/edit/src/apply.rs @@ -5,7 +5,7 @@ use super::place::{ END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place, place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role, }; -use super::{Document, EditError, Object, added, helvetica_width}; +use super::{Document, EditError, Object, added, backdrop, helvetica_width}; /// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on /// those pages are removed first (Acrobat's Replace Existing). @@ -199,7 +199,9 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac Ok(()) } -/// Add a solid-colour background behind the content of `pages`. +/// Add a solid-colour background behind the content of `pages`. With `bg.replace_backdrop`, a +/// white backdrop a page paints itself is replaced first, so the background shows (see +/// [`backdrop`]); a picture backdrop (a scan) still hides it. pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> { let all = page_list(doc); check(pages, all.len())?; @@ -208,7 +210,10 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl } let opacity = if bg.opacity.is_finite() { bg.opacity.clamp(0.0, 1.0) } else { 1.0 }; for &i in pages { - let page = page_list(doc)[i].clone(); + let mut page = page_list(doc)[i].clone(); + if bg.replace_backdrop && backdrop::replace(doc, &page)? { + page = page_list(doc)[i].clone(); + } let (w, h) = page.display_size(doc); let mut content = begin(MarkKind::Background, "Background", page.view_matrix(doc)); match &bg.source { @@ -231,13 +236,18 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl /// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with /// nothing after them go too, restoring the original content list; a wrapper that content -/// after it still relies on (another tool's appended stream) stays. +/// after it still relies on (another tool's appended stream) stays. Removing backgrounds also +/// turns a backdrop [`add_background`] replaced back on where that is still safe; the pages where +/// it isn't are [`backdrops_left_off`]. pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result { let all = page_list(doc); check(pages, all.len())?; let mut removed = 0; for &i in pages { let page = &all[i]; + if kind == MarkKind::Background { + backdrop::restore(doc, page)?; + } let list = contents(doc, page)?; let before = list.len(); let mut kept: Vec = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect(); diff --git a/crates/edit/src/backdrop.rs b/crates/edit/src/backdrop.rs new file mode 100644 index 0000000000..156501d95e --- /dev/null +++ b/crates/edit/src/backdrop.rs @@ -0,0 +1,563 @@ +//! A page's own backdrop: an opaque white fill (or a picture) covering the whole visible page +//! before anything else is drawn. Many writers paint one (browsers' Save as PDF, print drivers; +//! scans are a page-sized picture), and it hides anything placed behind the content, so a +//! background added behind such a page never shows. +//! +//! A white fill can be replaced: the one fill operator becomes `n` (end the path without +//! painting), which looks the same on white paper. The page then gets a copy of the content +//! stream that held it, tagged `/PCBackdrop << /Index n /Op (f) /Before /Was [num gen] >>`: +//! how many of the copy's operators come before the `n`, the operator it stands in for, a hash of +//! those operators (their values, not their spacing: redaction writes a stream out again with +//! other whitespace), and the number of the stream it was copied from. The stream itself is left +//! alone (other pages may share it). [`restore`] turns the fill back on when the background is +//! removed, even after the page was edited or redacted, as long as the operators before the fill +//! are unchanged and the fill would again be the first thing painted, covering the page. +//! Otherwise the fill stays off, and [`backdrops_left_off`] names those pages. A picture can't be +//! replaced without losing it, so it is only reported. +//! +//! The tag holds no page content and no reference that keeps another object alive (`/Was` is two +//! numbers, not a reference): a reference to the original stream would keep text in the file +//! that a redaction, Remove Hidden Information or Edit Text removed from the copy. +//! +//! Detection is deliberately strict: only one rectangle (`re`), in DeviceGray, DeviceRGB or +//! DeviceCMYK, opaque, unmasked, normally blended and with the identity transfer function as the +//! graphics state stands after every `gs` so far (each sets only its own keys), with an +//! axis-aligned transform, filled before any other painting, and covering the crop box. +//! Anything else is real content and stays. + +use pdfcraft_content::{Matrix, Op, Pieces}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +use sha2::{Digest, Sha256}; + +use crate::{EditError, MarkKind}; + +/// The dictionary key of a backdrop copy's tag. +const TAG: &[u8] = b"PCBackdrop"; +/// The fill operators a backdrop can be painted with, and so the only ones a tag may restore. +const FILLS: [&[u8]; 3] = [b"f", b"F", b"f*"]; + +/// How much of a page's content is read looking for a backdrop: it has to come first. +const SCAN_BYTES: usize = 64 * 1024; +/// How many operators are walked before giving up. +const SCAN_OPS: usize = 4096; +/// How far (in points) a backdrop may fall short of the crop box and still count as covering it. +const COVER_EPS: f64 = 1.0; +/// Components at least this close to white (per channel) count as white: a near-white fill +/// (`0.99 g`) is a backdrop too, and replacing it changes the page by at most 2%. +const WHITE: f64 = 0.98; + +/// What paints a page's backdrop. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Backdrop { + /// An opaque white fill: replaceable. + Fill, + /// A picture covering the page (a scan): a background behind it stays hidden. + Image, +} + +/// Where a replaceable fill is: the content streams (references in `/Contents` order, untagged +/// ones only), their joined bytes, where each starts in them, and the fill operator's joined +/// byte range. +struct Found { + refs: Vec<(usize, ObjRef)>, + pieces: Pieces, + starts: Vec, + op: std::ops::Range, +} + +impl Found { + /// The piece holding the fill operator, and the operator's offset in it. + fn place(&self) -> Option<(usize, usize)> { + let piece = self.pieces.piece_of(self.op.start); + Some((piece, self.op.start.checked_sub(*self.starts.get(piece)?)?)) + } +} + +#[derive(Clone)] +struct State { + ctm: Matrix, + /// Clip in default user space, when it is a known rectangle; `None` = unknown shape. + clip: Option<[f64; 4]>, + /// The fill colour is white. + white: bool, + /// Non-stroking colour space components (1, 3, 4), or 0 for a space we don't read. + space: usize, + paint: Paint, +} + +/// The graphics-state parameters (ExtGState, ISO 32000-2 §8.4.5) that decide whether a fill +/// paints its colour as given. A `gs` sets only the keys its dictionary has, so each is kept on +/// its own: a later dictionary that leaves one out keeps the earlier value. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Paint { + /// Non-stroking alpha (`ca`) is 1. + full_alpha: bool, + /// No soft mask (`SMask` is `/None`). + no_soft_mask: bool, + /// The blend mode (`BM`) is Normal or Compatible. + normal_blend: bool, + /// The transfer function (`TR`, or `TR2`, which takes precedence) is the identity: a + /// function could turn white into any colour. + identity_transfer: bool, +} + +impl Paint { + const INITIAL: Paint = Paint { full_alpha: true, no_soft_mask: true, normal_blend: true, identity_transfer: true }; + + /// A fill paints its colour exactly, opaque. + fn plain(self) -> bool { + self.full_alpha && self.no_soft_mask && self.normal_blend && self.identity_transfer + } +} + +/// The backdrop `page` (0-based) paints, if any. Finding the page walks the page tree, so to +/// check many pages use [`page_backdrops`], which walks it once. +pub fn page_backdrop(doc: &Document, page: usize) -> Result, EditError> { + let all = pdfcraft_model::pages(doc); + let p = all.get(page).ok_or(EditError::NoSuchPage(page))?; + Ok(scan(doc, p, None).map(|(b, _)| b)) +} + +/// The backdrop each of `pages` (0-based) paints, in the same order, walking the page tree once. +/// A page past the end has none, and doesn't hide what the others paint: a page count taken +/// elsewhere (the viewer's) can disagree with the page tree's on a malformed document. +pub fn page_backdrops(doc: &Document, pages: &[usize]) -> Vec> { + let all = pdfcraft_model::pages(doc); + pages.iter().map(|&p| all.get(p).and_then(|page| scan(doc, page, None)).map(|(b, _)| b)).collect() +} + +/// Pages (0-based, ascending) whose own white backdrop [`replace`] turned off and [`restore`] +/// couldn't turn back on: a tagged copy is still in their content, with no background over it. +/// They look the same on white paper. +pub fn backdrops_left_off(doc: &Document) -> Vec { + let tag_of = |o: &Object| crate::place::stream_tag(doc, o); + let copy = |o: &Object| doc.resolve(o).as_dict().is_some_and(|d| d.get(TAG).is_some()); + pdfcraft_model::pages(doc) + .iter() + .enumerate() + .filter(|(_, p)| { + let list: Vec = match p.dict.get(b"Contents").map(|c| doc.resolve(c)).as_deref() { + Some(Object::Array(a)) => a.clone(), + Some(_) => p.dict.get(b"Contents").cloned().into_iter().collect(), + None => Vec::new(), + }; + list.iter().any(copy) && !list.iter().any(|o| tag_of(o).as_deref() == Some(MarkKind::Background.tag())) + }) + .map(|(i, _)| i) + .collect() +} + +/// Replace `page`'s white backdrop fill, if it has one (see the module doc). Returns whether it +/// did; when it did, `page` no longer matches the document. A picture backdrop is left alone. +pub(crate) fn replace(doc: &mut Document, page: &pdfcraft_model::Page) -> Result { + let Some((Backdrop::Fill, Some(found))) = scan(doc, page, None) else { return Ok(false) }; + let Some((piece, at)) = found.place() else { return Ok(false) }; + let op = found.pieces.data().get(found.op.clone()).unwrap_or_default().to_vec(); + let edited = found.pieces.splice([(found.op.clone(), b"n".to_vec())]); + let (Some(&(_, original)), Some(bytes)) = (found.refs.get(piece), edited.get(piece)) else { return Ok(false) }; + // The `n` among the copy's own operators. A copy whose fill can't be found that way again is + // not made: there would be no safe way back. + let parsed = pdfcraft_content::parse(head(bytes)); + let Some(index) = parsed.ops.iter().position(|o| o.span.start == at && o.op == b"n") else { return Ok(false) }; + let Some(before) = parsed.ops.get(..index) else { return Ok(false) }; + let mut note = Dict::new(); + note.set(b"Index".to_vec(), Object::Int(i64::try_from(index).unwrap_or(i64::MAX))); + note.set(b"Op".to_vec(), Object::String(PdfString::literal(op))); + note.set(b"Before".to_vec(), Object::String(PdfString { bytes: ops_hash(before), hex: true })); + note.set(b"Was".to_vec(), Object::Array(vec![Object::Int(original.num.into()), Object::Int(original.generation.into())])); + // The copy keeps the original's dictionary (unknown entries included) less what describes + // the old encoding; `scan` refused streams whose bytes live in an external file (`/F`). + let Object::Stream(source) = &*doc.get(original) else { return Ok(false) }; + let mut dict = source.dict.clone(); + for key in [&b"Length"[..], b"DL", TAG] { + dict.remove(key); + } + dict.set(TAG.to_vec(), Object::Dict(note)); + let copy = doc.add(Object::Stream(Stream::flate(dict, bytes))); + // Every slot that draws the original: a page may list the same stream twice, and a second + // copy of the fill would cover the background again. + let mut list = crate::place::contents(doc, page)?; + let mut replaced = false; + for entry in list.iter_mut().filter(|o| matches!(o, Object::Ref(r) if *r == original)) { + *entry = Object::Ref(copy); + replaced = true; + } + if replaced { + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + } + Ok(replaced) +} + +/// Turn the fills [`replace`] turned off on `page` back on, where [`restored_copy`] finds that +/// safe; a copy listed in several slots is restored in each. Returns how many slots it restored. +/// A copy it can't restore stays as it is, tag and all, so [`backdrops_left_off`] names its page. +pub(crate) fn restore(doc: &mut Document, page: &pdfcraft_model::Page) -> Result { + let mut list = crate::place::contents(doc, page)?; + // Per copy: the stream that takes its place, or `None` when it stays. + let mut done: Vec<(ObjRef, Option)> = Vec::new(); + let mut restored = 0; + for entry in list.iter_mut() { + let Object::Ref(copy) = *entry else { continue }; + let with = match done.iter().find(|(c, _)| *c == copy) { + Some(&(_, with)) => with, + None => { + let with = restored_copy(doc, page, copy).map(|(bytes, dict, was)| { + // The stream it was copied from, while that is still here with these bytes + // (other pages may share it): the page is then exactly as it was. `/Was` is + // only a number, and a full save renumbers objects, so the bytes decide. + let same = |w: &ObjRef| match &*doc.get(*w) { + Object::Stream(s) => { + [TAG, &b"PCMark"[..], b"Subtype"].iter().all(|k| s.dict.get(k).is_none()) + && s.decoded().ok().as_deref() == Some(bytes.as_slice()) + } + _ => false, + }; + match was.filter(same) { + Some(w) => w, + None => doc.add(Object::Stream(Stream::flate(dict, &bytes))), + } + }); + done.push((copy, with)); + with + } + }; + if let Some(with) = with { + *entry = Object::Ref(with); + restored += 1; + } + } + if restored > 0 { + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + } + Ok(restored) +} + +/// `copy` (a stream [`replace`] wrote for `page`) with its fill turned back on: the bytes, its +/// dictionary without the tag, and the stream it was copied from. `None` when it isn't such a +/// copy, or when turning the fill on could paint over something: the operators before the fill +/// changed since (their hash no longer matches), or the page would no longer paint the fill +/// first, covering it. What comes after the fill (an edit, a redaction) is kept as it is. +fn restored_copy(doc: &Document, page: &pdfcraft_model::Page, copy: ObjRef) -> Option<(Vec, Dict, Option)> { + let obj = doc.get(copy); + let Object::Stream(s) = &*obj else { return None }; + let note = s.dict.get(TAG).map(|n| doc.resolve(n))?; + let note = note.as_dict()?; + let index = usize::try_from(note.get(b"Index")?.as_int()?).ok()?; + let op = note.get(b"Op")?.as_string()?.bytes.as_slice(); + if !FILLS.contains(&op) { + return None; + } + let hash = note.get(b"Before")?.as_string()?; + let bytes = s.decoded().ok()?; + let parsed = pdfcraft_content::parse(head(&bytes)); + // The `n` standing in for the fill, after exactly the operators that came before it. + let fill = parsed.ops.get(index)?; + if fill.op != b"n" || !fill.operands.is_empty() || ops_hash(parsed.ops.get(..index)?) != hash.bytes { + return None; + } + let mut out = Vec::with_capacity(bytes.len().saturating_add(op.len())); + out.extend_from_slice(bytes.get(..fill.span.start)?); + out.extend_from_slice(op); + out.extend_from_slice(bytes.get(fill.span.end..)?); + let (Backdrop::Fill, Some(found)) = scan(doc, page, Some((copy, &out)))? else { return None }; + let (piece, offset) = found.place()?; + if found.refs.get(piece).map(|&(_, r)| r) != Some(copy) || offset != fill.span.start || found.op.len() != op.len() { + return None; + } + let mut dict = s.dict.clone(); + for key in [&b"Length"[..], b"DL", TAG] { + dict.remove(key); + } + let was = match note.get(b"Was").and_then(Object::as_array).map(Vec::as_slice) { + Some([num, generation]) => Some(ObjRef { num: u32::try_from(num.as_int()?).ok()?, generation: u16::try_from(generation.as_int()?).ok()? }), + _ => None, + }; + Some((out, dict, was)) +} + +/// As much of a content stream as a backdrop could be in: it has to come first. +fn head(bytes: &[u8]) -> &[u8] { + bytes.get(..bytes.len().min(SCAN_BYTES)).unwrap_or_default() +} + +/// A SHA-256 of what `ops` are, not how they are written: keywords and operand values, with +/// numbers in one form (`1`, `1.0` and `1.00000` are the same), so a stream written out again +/// with other spacing or number formatting still matches. +fn ops_hash(ops: &[Op]) -> Vec { + let mut h = Sha256::new(); + for op in ops { + for o in &op.operands { + canonical(o, &mut h, 0); + } + if let Some((dict, data)) = &op.inline { + canonical_dict(dict, &mut h, 0); + bytes(b'i', data, &mut h); + } + bytes(b'k', &op.op, &mut h); + } + h.finalize().to_vec() +} + +/// How deep [`canonical`] follows nested arrays and dictionaries; deeper ones count as equal. +const CANONICAL_DEPTH: usize = 32; + +fn canonical(o: &Object, h: &mut Sha256, depth: usize) { + if depth > CANONICAL_DEPTH { + h.update(b"~"); + return; + } + match o { + Object::Null => h.update(b"z"), + Object::Bool(b) => h.update(if *b { b"T" } else { b"F" }), + Object::Int(i) => number(&i.to_string(), h), + Object::Real(r) if *r == 0.0 => number("0", h), + Object::Real(r) => number(&format!("{r:.5}"), h), + Object::String(s) => bytes(b's', &s.bytes, h), + Object::Name(n) => bytes(b'/', n, h), + Object::Array(a) => { + h.update(b"["); + for x in a { + canonical(x, h, depth.saturating_add(1)); + } + h.update(b"]"); + } + Object::Dict(d) => canonical_dict(d, h, depth), + Object::Stream(_) | Object::Ref(_) => h.update(b"?"), + } +} + +fn canonical_dict(d: &Dict, h: &mut Sha256, depth: usize) { + h.update(b"<"); + for (k, v) in d.iter() { + bytes(b'/', k, h); + canonical(v, h, depth.saturating_add(1)); + } + h.update(b">"); +} + +/// A number written with up to five decimals, without trailing zeros. +fn number(text: &str, h: &mut Sha256) { + let t = if text.contains('.') { text.trim_end_matches('0').trim_end_matches('.') } else { text }; + bytes(b'#', t.as_bytes(), h); +} + +/// A typed, length-prefixed value, so no two different sequences hash the same way. +fn bytes(kind: u8, b: &[u8], h: &mut Sha256) { + h.update([kind]); + h.update(u64::try_from(b.len()).unwrap_or(u64::MAX).to_le_bytes()); + h.update(b); +} + +/// Find the backdrop, and for a fill, where it is. With `swap`, that stream reads as those bytes +/// instead of its own (to check a change before making it). +fn scan(doc: &Document, p: &pdfcraft_model::Page, swap: Option<(ObjRef, &[u8])>) -> Option<(Backdrop, Option)> { + let crop = p.crop(doc); + let c = p.dict.get(b"Contents")?; + let list: Vec = match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }; + // The page's own content: untagged streams (marks and the q/Q wrapper carry /PCMark). Read + // only as far as a backdrop could be. + let mut refs = Vec::new(); + let mut data = Vec::new(); + let mut starts = Vec::new(); + let mut total = 0usize; + for (slot, o) in list.iter().enumerate() { + if total >= SCAN_BYTES { + break; + } + let Object::Ref(r) = o else { return None }; + let obj = doc.get(*r); + let Object::Stream(s) = &*obj else { continue }; + if s.dict.get(b"PCMark").is_some() { + continue; + } + // Bytes in an external file (`/F`) replace the stream's own: what it draws is unknown here. + if s.dict.get(b"F").is_some() { + return None; + } + let bytes = match swap { + Some((with, b)) if with == *r => b.to_vec(), + _ => s.decoded().unwrap_or_default(), + }; + starts.push(total); + total = total.saturating_add(bytes.len()).saturating_add(1); + refs.push((slot, *r)); + data.push(bytes); + } + let pieces = Pieces::join(&data); + let parsed = pdfcraft_content::parse(head(pieces.data())); + let res = resources(doc, &p.dict); + let found = walk(doc, &res, crop, &parsed.ops); + found.map(|(b, op)| match (b, op) { + (Backdrop::Fill, Some(op)) => (b, Some(Found { refs, pieces, starts, op })), + _ => (b, None), + }) +} + +fn resources(doc: &Document, page: &Dict) -> Dict { + page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default() +} + +fn sub(doc: &Document, res: &Dict, kind: &[u8], name: &[u8]) -> Option> { + let d = res.get(kind).map(|d| doc.resolve(d))?; + let o = d.as_dict()?.get(name)?.clone(); + Some(doc.resolve(&o)) +} + +/// Walk the operators until the first one that paints; a backdrop is that one, or nothing. +fn walk(doc: &Document, res: &Dict, crop: [f64; 4], ops: &[Op]) -> Option<(Backdrop, Option>)> { + let mut st = State { ctm: Matrix::IDENTITY, clip: Some([f64::MIN, f64::MIN, f64::MAX, f64::MAX]), white: false, space: 1, paint: Paint::INITIAL }; + let mut saved: Vec = Vec::new(); + // Current path: its rectangles in user space, or `None` once it is anything else. + let mut path: Option> = Some(Vec::new()); + let mut clip_next = false; + for op in ops.iter().take(SCAN_OPS) { + match op.op.as_slice() { + b"q" => { + if saved.len() >= 64 { + return None; + } + saved.push(st.clone()); + } + b"Q" => st = saved.pop()?, + b"cm" => { + let m = Matrix::from_operands(op.operands.get(op.operands.len().checked_sub(6)?..)?)?; + if !m.0.iter().all(|v| v.is_finite()) { + return None; + } + st.ctm = m.then(&st.ctm); + } + b"gs" => apply_state(doc, res, op.name(0)?, &mut st.paint)?, + b"g" => set_colour(&mut st, 1, op), + b"rg" => set_colour(&mut st, 3, op), + b"k" => set_colour(&mut st, 4, op), + b"cs" => { + st.space = match op.name(0)? { + b"DeviceGray" => 1, + b"DeviceRGB" => 3, + b"DeviceCMYK" => 4, + _ => 0, + }; + // The initial colour of these spaces is black. + st.white = false; + } + b"sc" | b"scn" => { + let n = st.space; + set_colour(&mut st, n, op) + } + b"re" => { + let [x, y, w, h] = op.nums::<4>()?; + let finite = [x, y, w, h].iter().all(|v| v.is_finite()); + match (&mut path, finite && axis_aligned(&st.ctm)) { + (Some(rects), true) => rects.push(st.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)])), + _ => path = None, + } + } + b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => path = None, + b"W" | b"W*" => clip_next = true, + b"n" => { + if clip_next { + st.clip = clipped(st.clip, path.as_deref()); + } + path = Some(Vec::new()); + clip_next = false; + } + b"f" | b"F" | b"f*" => { + // One rectangle: with two, a fill rule can cut a hole where content shows. + let covers = match path.as_deref() { + Some([r]) => st.clip.and_then(|c| intersect(*r, c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS)), + _ => false, + }; + return (covers && st.white && st.paint.plain() && !clip_next).then(|| (Backdrop::Fill, Some(op.span.clone()))); + } + b"Do" => { + let xo = sub(doc, res, b"XObject", op.name(0)?)?; + let Object::Stream(s) = &*xo else { return None }; + // A stencil or masked picture lets what is behind it show. + let masked = [&b"SMask"[..], b"Mask"].iter().any(|k| s.dict.get(k).is_some()) + || s.dict.get(b"ImageMask").is_some_and(|m| matches!(*doc.resolve(m), Object::Bool(true))); + let image = s.dict.name(b"Subtype") == Some(b"Image") && !masked; + return covers_unit_square(&st, crop, image).then_some((Backdrop::Image, None)); + } + b"BI" => { + let masked = op + .inline + .as_ref() + .is_some_and(|(d, _)| [&b"IM"[..], b"ImageMask"].iter().any(|k| d.get(k).is_some_and(|m| matches!(m, Object::Bool(true))))); + return covers_unit_square(&st, crop, !masked).then_some((Backdrop::Image, None)); + } + // Anything else that paints comes first: no backdrop. + b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" | b"sh" | b"Tj" | b"TJ" | b"'" | b"\"" | b"EI" | b"d0" | b"d1" => return None, + _ => {} + } + } + None +} + +/// A picture placed by the CTM (onto the unit square) covering the crop box, opaque, unclipped. +fn covers_unit_square(st: &State, crop: [f64; 4], image: bool) -> bool { + image + && st.paint.plain() + && axis_aligned(&st.ctm) + && st.clip.and_then(|c| intersect(st.ctm.bbox([0.0, 0.0, 1.0, 1.0]), c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS)) +} + +fn set_colour(st: &mut State, components: usize, op: &Op) { + st.space = components; + let vals: Vec = op.operands.iter().filter_map(Object::as_f64).collect(); + st.white = vals.len() == components + && match components { + 1 | 3 => vals.iter().all(|v| *v >= WHITE), + 4 => vals.iter().all(|v| *v <= 1.0 - WHITE), + _ => false, + }; +} + +/// Apply the named ExtGState's keys that [`Paint`] tracks; the keys it doesn't have leave +/// `paint` as it was. `None` when the dictionary or one of those values can't be read. +fn apply_state(doc: &Document, res: &Dict, name: &[u8], paint: &mut Paint) -> Option<()> { + let gs = sub(doc, res, b"ExtGState", name)?; + let gs = gs.as_dict()?; + if let Some(alpha) = gs.get(b"ca") { + paint.full_alpha = doc.resolve(alpha).as_f64()? >= 0.999; + } + if let Some(mask) = gs.get(b"SMask") { + paint.no_soft_mask = doc.resolve(mask).as_name() == Some(b"None"); + } + if let Some(blend) = gs.get(b"BM") { + paint.normal_blend = matches!(doc.resolve(blend).as_name(), Some(b"Normal" | b"Compatible")); + } + // TR2 takes precedence over TR in the same dictionary (§8.4.5, Table 57). Only the names + // are read: a function (or one per colourant) is taken to change white. + if let Some(transfer) = gs.get(b"TR2") { + paint.identity_transfer = matches!(doc.resolve(transfer).as_name(), Some(b"Identity" | b"Default")); + } else if let Some(transfer) = gs.get(b"TR") { + paint.identity_transfer = doc.resolve(transfer).as_name() == Some(b"Identity"); + } + Some(()) +} + +/// Rotations by multiples of 90° and scales only: rectangles stay rectangles. +fn axis_aligned(m: &Matrix) -> bool { + let [a, b, c, d, ..] = m.0; + (b.abs() < 1e-9 && c.abs() < 1e-9) || (a.abs() < 1e-9 && d.abs() < 1e-9) +} + +fn intersect(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> { + let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]; + (r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r) +} + +/// The clip after intersecting with a path: known only when the path is one rectangle. +fn clipped(clip: Option<[f64; 4]>, path: Option<&[[f64; 4]]>) -> Option<[f64; 4]> { + match path { + Some([r]) => intersect(clip?, *r).or(Some([0.0, 0.0, 0.0, 0.0])), + _ => None, + } +} + +#[cfg(test)] +#[path = "backdrop_tests.rs"] +mod tests; diff --git a/crates/edit/src/backdrop_tests.rs b/crates/edit/src/backdrop_tests.rs new file mode 100644 index 0000000000..6ca100adcb --- /dev/null +++ b/crates/edit/src/backdrop_tests.rs @@ -0,0 +1,558 @@ +use std::sync::Arc; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental}; + +use super::*; +use crate::{Background, MarkKind, add_background, remove_marks}; + +const TEXT: &str = "BT /F1 24 Tf 40 90 Td (Hello) Tj ET"; + +/// A document from its objects (object 1 is the catalog, 2 the page tree). +fn build(objs: &[String]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn stream(dict: &str, data: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + +/// One 300 × 200 page drawing `pieces` (objects 5…), with a font, a page-sized image `/Im0` +/// (object 4) and two graphics states, `/Half` (ca 0.5) and `/Full` (ca 1). +/// The graphics states [`page`] offers. `INVERT` is a transfer function that maps 1 to 0, so +/// `1 g` through it paints black (#2815 review). +const GRAPHICS_STATES: &str = concat!( + "/Half << /ca 0.5 >> /Full << /ca 1 /CA 1 >> /LineOnly << /LW 2 >> ", + "/Masked << /SMask << /S /Luminosity /G 4 0 R >> >> /Unmasked << /SMask /None >> ", + "/Multiply << /BM /Multiply >> /NormalBlend << /BM /Normal >> ", + "/Invert << /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> ", + "/Invert2 << /TR2 << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> ", + "/IdentityTR << /TR /Identity >> /DefaultTR2 << /TR2 /Default >> ", + "/InvertButTR2Default << /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> /TR2 /Default >> ", + "/IdentityButTR2Invert << /TR /Identity /TR2 << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >>", +); + +fn page(pieces: &[&str]) -> Document { + let refs: Vec = (0..pieces.len()).map(|i| format!("{} 0 R", i + 5)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents [{}] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 4 0 R >> /ExtGState << {GS} >> >> >>", + refs.join(" "), + GS = GRAPHICS_STATES + ), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "\u{80}"), + ]; + objs.extend(pieces.iter().map(|p| stream("", p))); + build(&objs) +} + +/// The decoded content of a page, stream by stream. +fn streams(doc: &Document) -> Vec { + let p = &pdfcraft_model::pages(doc)[0]; + let list = match p.dict.get(b"Contents").map(|c| doc.resolve(c)).as_deref() { + Some(Object::Array(a)) => a.clone(), + _ => vec![], + }; + list.iter() + .map(|o| match &*doc.resolve(o) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), + _ => String::new(), + }) + .collect() +} + +fn reopen(doc: &Document) -> Document { + let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); + Document::open(Arc::new(bytes)).unwrap() +} + +fn red() -> Background { + Background { color: [1.0, 0.0, 0.0], ..Background::default() } +} + +#[test] +fn a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes() { + let content = format!("q 1 1 1 rg 0 0 300 200 re f Q {TEXT}"); + let mut doc = page(&[&content]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); + let original = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let s = streams(&reopen(&doc)); + assert_eq!(s.len(), 2, "{s:?}"); + assert!(s[0].contains("/PCMark /Background")); + assert_eq!(s[1], format!("q 1 1 1 rg 0 0 300 200 re n Q {TEXT}"), "only the fill operator changed"); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "nothing hides the background now"); + // The original stream object is untouched; the page points at a copy. + let Object::Stream(orig) = &*doc.get(ObjRef { num: 5, generation: 0 }) else { panic!("object 5 is the content") }; + assert_eq!(String::from_utf8_lossy(&orig.decoded().unwrap()), content); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1); + assert_eq!(streams(&reopen(&doc)), original, "exactly the original content"); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); +} + +#[test] +fn updating_the_background_replaces_the_backdrop_once_and_removing_still_restores() { + let mut doc = page(&[&format!("1 1 1 rg 0 0 300 200 re f 0 g {TEXT}")]); + let original = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + add_background(&mut doc, &[0], &Background { color: [0.0, 0.0, 1.0], ..Background::default() }, true).unwrap(); + let s = streams(&doc); + assert_eq!(s.len(), 2, "one background, one copy: {s:?}"); + assert!(s[1].contains("re n")); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), original); +} + +#[test] +fn a_backdrop_split_across_content_streams_is_replaced_in_the_piece_holding_the_operator() { + let mut doc = page(&["1 1 1 rg 0 0 300 200 re", &format!("f* {TEXT}")]); + let original = streams(&doc); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let s = streams(&doc); + assert_eq!(s[1..], ["1 1 1 rg 0 0 300 200 re".to_string(), format!("n {TEXT}")], "{s:?}"); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), original); +} + +#[test] +fn backdrops_writers_paint_are_recognised() { + for content in [ + // Browsers: a flipped, scaled coordinate system and a page clip first. + "q 0.5 0 0 -0.5 0 200 cm 0 0 600 400 re W n 1 1 1 rg 0 0 600 400 re f Q", + // Larger than the page, in CMYK, through an opaque graphics state. + "/Full gs 0 0 0 0 k -10 -10 320 220 re f", + "/DeviceRGB cs 1 1 1 sc 0 0 300 200 re F", + "0.99 g 0 0 300 200 re f", + // Turned a quarter: still a rectangle. + "0 1 -1 0 300 0 cm 1 g 0 0 200 300 re f", + ] { + let doc = page(&[&format!("{content} 0 g {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill), "{content}"); + } +} + +#[test] +fn content_that_is_not_a_page_backdrop_is_left_alone() { + for content in [ + // Not the whole page. + "1 g 0 0 300 150 re f", + "q 0 0 100 100 re W n 1 g 0 0 300 200 re f Q", + // Not white, or not opaque. + "0.5 g 0 0 300 200 re f", + "1 0 0 rg 0 0 300 200 re f", + "0 0 0 0.5 k 0 0 300 200 re f", + "/Half gs 1 g 0 0 300 200 re f", + "/Missing gs 1 g 0 0 300 200 re f", + "/Pattern cs /P0 scn 0 0 300 200 re f", + // The initial colour is black. + "0 0 300 200 re f", + // Something else is painted first. + "BT /F1 9 Tf (x) Tj ET 1 g 0 0 300 200 re f", + "0 g 0 0 1 1 re f 1 g 0 0 300 200 re f", + "1 g 0 0 300 200 re S 1 g 0 0 300 200 re f", + // Not a single rectangle: a hole, a turned square, a fill-and-stroke. + "1 g 0 0 300 200 re 50 50 10 10 re f*", + "1 g 0 0 m 300 0 l 300 200 l 0 200 l h f", + "0.7 0.7 -0.7 0.7 150 -100 cm 1 g 0 0 400 400 re f", + "1 g 0 0 300 200 re B", + // Malformed. + "Q 1 g 0 0 300 200 re f", + "1 g 0 0 300 re f", + "1 g 0 0 1e400 200 re f", + ] { + let mut doc = page(&[&format!("{content} {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "{content}"); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..], "{content}: the page's content is unchanged"); + } +} + +#[test] +fn a_scanned_page_is_reported_and_its_picture_kept() { + let mut doc = page(&[&format!("q 300 0 0 200 0 0 cm /Im0 Do Q {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Image)); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..]); + // A small picture is content, not a backdrop. + let doc = page(&["q 30 0 0 20 0 0 cm /Im0 Do Q"]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); +} + +#[test] +fn replacing_can_be_turned_off() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + let before = streams(&doc); + add_background(&mut doc, &[0], &Background { replace_backdrop: false, ..red() }, false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..]); +} + +/// Rewrite the page's backdrop copy (the stream after the background) as another tool would: +/// Edit text and redaction keep its dictionary and change its bytes. +fn rewrite_copy(doc: &mut Document, bytes: &str) -> ObjRef { + let p = &pdfcraft_model::pages(doc)[0]; + let Some(Object::Array(list)) = p.dict.get(b"Contents").cloned() else { panic!("contents") }; + let Object::Ref(copy) = list[1] else { panic!("a reference") }; + let Object::Stream(s) = (*doc.get(copy)).clone() else { panic!("a stream") }; + assert!(s.dict.get(b"PCBackdrop").is_some(), "the copy"); + doc.set(copy, Object::Stream(Stream::flate(s.dict.clone(), bytes.as_bytes()))); + copy +} + +#[test] +fn an_edit_after_the_fill_is_kept_and_the_backdrop_still_comes_back() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + rewrite_copy(&mut doc, "1 g 0 0 300 200 re n BT /F1 24 Tf 40 90 Td (Edited) Tj ET"); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), ["1 g 0 0 300 200 re f BT /F1 24 Tf 40 90 Td (Edited) Tj ET"], "the edit survives, under the fill"); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); + assert_eq!(backdrops_left_off(&doc), Vec::::new()); +} + +#[test] +fn a_change_before_the_fill_leaves_it_off_and_names_the_page() { + for edited in [ + // A different colour before the fill: the hash of what comes before it no longer matches. + "0.99 g 0 0 300 200 re n BT /F1 24 Tf 40 90 Td (Edited) Tj ET", + // Something painted before it. + "BT /F1 9 Tf (x) Tj ET 1 g 0 0 300 200 re n", + // The fill is gone. + "BT /F1 24 Tf 40 90 Td (Edited) Tj ET", + ] { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(backdrops_left_off(&doc), Vec::::new(), "a background is over it"); + rewrite_copy(&mut doc, edited); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), [edited.to_string()], "{edited}: the copy stays as it is"); + assert_eq!(backdrops_left_off(&doc), [0], "{edited}"); + } +} + +#[test] +fn a_fill_that_would_cover_content_painted_before_it_stays_off() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + // A tool puts a stream of its own before the copy, untagged: turning the fill back on would + // now paint white over that text. + let over = doc.add(Object::Stream(Stream::flate(pdfcraft_cos::Dict::new(), b"BT /F1 9 Tf 10 10 Td (Over) Tj ET"))); + let p = pdfcraft_model::pages(&doc)[0].clone(); + let Some(Object::Array(mut list)) = p.dict.get(b"Contents").cloned() else { panic!("contents") }; + list.insert(1, Object::Ref(over)); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list))).unwrap(); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + let s = streams(&doc); + assert_eq!(s.len(), 2, "{s:?}"); + assert!(s[1].contains("re n") && !s[1].contains("re f"), "nothing white goes over the text: {s:?}"); + assert_eq!(backdrops_left_off(&doc), [0]); +} + +#[test] +fn the_tag_holds_no_reference_and_a_full_save_keeps_no_second_copy_of_the_page() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + // Every stream in the file that holds the page's text: only the copy the page draws. A + // reference from the tag to the original stream kept a second, which a redaction or Remove + // Hidden Information on the copy left in the file. + let holding: Vec<(ObjRef, Dict)> = saved + .scan_objects() + .filter_map(|(r, o)| match &*o { + Object::Stream(s) if String::from_utf8_lossy(&s.decoded().unwrap_or_default()).contains("(Hello)") => Some((r, s.dict.clone())), + _ => None, + }) + .collect(); + assert_eq!(holding.len(), 1, "{holding:?}"); + let Some(Object::Dict(note)) = holding[0].1.get(b"PCBackdrop").map(|n| (*saved.resolve(n)).clone()) else { panic!("the tag") }; + fn refs(o: &Object) -> bool { + match o { + Object::Ref(_) => true, + Object::Array(a) => a.iter().any(refs), + Object::Dict(d) => d.iter().any(|(_, v)| refs(v)), + _ => false, + } + } + assert!(!note.iter().any(|(_, v)| refs(v)), "{note:?}"); + assert_eq!(note.get(b"Op").and_then(Object::as_string).map(|s| s.bytes.clone()), Some(b"f".to_vec())); + assert_eq!(note.get(b"Before").and_then(Object::as_string).map(|s| s.bytes.len()), Some(32), "a SHA-256"); + // The original is gone, so Remove writes the fill back into a stream of its own. + let mut saved = saved; + remove_marks(&mut saved, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&saved), [format!("1 g 0 0 300 200 re f {TEXT}")]); + assert_eq!(page_backdrop(&saved, 0).unwrap(), Some(Backdrop::Fill)); +} + +#[test] +fn a_copy_written_out_again_with_other_spacing_still_comes_back() { + // Redaction writes the whole stream out again: one operator a line, numbers its own way. + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + rewrite_copy(&mut doc, "1.0 g\n0 0 300.000 200 re\nn\nBT\n/F1 24 Tf\n40 90 Td\n[-6505] TJ\nET"); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), ["1.0 g\n0 0 300.000 200 re\nf\nBT\n/F1 24 Tf\n40 90 Td\n[-6505] TJ\nET"], "the fill is back, the rest as written"); + assert_eq!(backdrops_left_off(&doc), Vec::::new()); +} + +#[test] +fn a_tag_that_does_not_add_up_is_left_alone() { + let content = format!("1 g 0 0 300 200 re f {TEXT}"); + let copied = format!("1 g 0 0 300 200 re n {TEXT}"); + let good = ops_hash(&pdfcraft_content::parse(b"1 g 0 0 300 200 re").ops); + let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + for tag in [ + // Not a fill operator, a wrong hash, an index that isn't the `n` or runs past the end. + format!("<< /Index 2 /Op (B) /Before <{}> >>", hex(&good)), + format!("<< /Index 2 /Op (f) /Before <{}> >>", hex(&[0; 32])), + format!("<< /Index 1 /Op (f) /Before <{}> >>", hex(&good)), + format!("<< /Index 99999 /Op (f) /Before <{}> >>", hex(&good)), + format!("<< /Index -1 /Op (f) /Before <{}> >>", hex(&good)), + // Missing entries, wrong types, and the reference earlier test builds wrote. + "<< /Index 2 /Op (f) >>".to_string(), + "<< /Index (2) /Op /f /Before 3 >>".to_string(), + "<< /Original 5 0 R /At 19 /Len 1 >>".to_string(), + "(not a dictionary)".to_string(), + ] { + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(), + stream(&format!("/PCBackdrop {tag}"), &copied), + stream("", &content), + ]); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), std::slice::from_ref(&copied), "{tag}"); + assert_eq!(backdrops_left_off(&doc), [0], "{tag}"); + } + // The same page with an honest tag comes back. + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(), + stream(&format!("/PCBackdrop << /Index 2 /Op (f) /Before <{}> >>", hex(&good)), &copied), + ]); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), [content]); +} + +#[test] +fn operators_hash_by_value_not_by_spelling() { + let h = |s: &[u8]| ops_hash(&pdfcraft_content::parse(s).ops); + assert_eq!(h(b"1 g 0 0 300 200 re"), h(b"1.0 g\n0 0 300.00000 200 re")); + assert_eq!(h(b"0 g"), h(b"-0.0 g")); + assert_ne!(h(b"1 g 0 0 300 200 re"), h(b"0.99 g 0 0 300 200 re")); + assert_ne!(h(b"(ab) Tj"), h(b"(a) (b) Tj"), "operand boundaries count"); + assert_ne!(h(b"/a /b BDC"), h(b"/ab BDC")); + assert_ne!(h(b""), h(b"q")); + // Nesting is bounded: deep arrays hash without running out of stack. + let deep = format!("{}{} Tj", "[".repeat(1000), "]".repeat(1000)); + let _ = h(deep.as_bytes()); +} + +#[test] +fn a_content_stream_shared_by_two_pages_is_copied_for_the_one_page() { + let content = format!("1 g 0 0 300 200 re f {TEXT}"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), + stream("", &content), + ]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); + assert_eq!(page_backdrop(&doc, 1).unwrap(), Some(Backdrop::Fill), "the other page keeps its backdrop"); +} + +#[test] +fn pages_without_content_or_with_odd_contents_have_no_backdrop() { + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [5 0 R 99 0 R] >>".into(), + "<< /Not /AStream >>".into(), + ]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); + assert_eq!(page_backdrop(&doc, 1).unwrap(), None); + assert!(matches!(page_backdrop(&doc, 7), Err(EditError::NoSuchPage(7)))); +} + +/// `pages` 300 × 200 pages in a flat tree; even pages paint a white backdrop, odd ones don't. +fn many_pages(pages: usize) -> Document { + let kids: Vec = (0..pages).map(|i| format!("{} 0 R", i + 5)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 300 200] >>", kids.join(" ")), + stream("", &format!("1 g 0 0 300 200 re f 0 g {TEXT}")), + stream("", TEXT), + ]; + objs.extend((0..pages).map(|i| format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R >>", 3 + i % 2))); + build(&objs) +} + +#[test] +fn checking_many_pages_walks_the_page_tree_once() { + // The Add Background dialog checks every page of a long document. Looking each page up on + // its own walked the whole tree per page, so checking them all grew with the square of the + // page count (minutes at 20,000 pages); one walk keeps it in proportion. + const PAGES: usize = 4000; + let doc = many_pages(PAGES); + let all: Vec = (0..PAGES).collect(); + let start = std::time::Instant::now(); + let found = page_backdrops(&doc, &all); + let took = start.elapsed(); + assert_eq!(found.len(), PAGES); + for (i, f) in found.iter().enumerate() { + assert_eq!(*f, (i % 2 == 0).then_some(Backdrop::Fill), "page {i}"); + } + assert!(took < std::time::Duration::from_secs(5), "checking {PAGES} pages took {took:?}"); +} + +#[test] +fn a_batch_answers_in_the_order_asked_and_a_missing_page_hides_nothing() { + let doc = many_pages(5); + assert_eq!(page_backdrops(&doc, &[3, 0, 0]), [None, Some(Backdrop::Fill), Some(Backdrop::Fill)]); + assert_eq!(page_backdrops(&doc, &[]), []); + // The viewer can count more pages than the page tree holds: the others still answer. + assert_eq!(page_backdrops(&doc, &[2, 5, usize::MAX]), [Some(Backdrop::Fill), None, None]); + assert_eq!(page_backdrop(&doc, 4).unwrap(), Some(Backdrop::Fill)); + assert!(matches!(page_backdrop(&doc, 5), Err(EditError::NoSuchPage(5)))); +} + +#[test] +fn a_background_over_many_pages_replaces_each_backdrop_and_removing_restores_them() { + // Pages share their content streams, so each replaced page gets its own copy and the rest + // are checked against the document as it is after the earlier ones. + let mut doc = many_pages(4); + let all = [0, 1, 2, 3]; + let before: Vec = pdfcraft_model::pages(&doc).iter().filter_map(|p| p.dict.get(b"Contents").cloned()).collect(); + add_background(&mut doc, &all, &red(), true).unwrap(); + assert_eq!(page_backdrops(&doc, &all), [None; 4]); + // Update: the backdrops are gone already, so nothing is copied again. + add_background(&mut doc, &all, &red(), true).unwrap(); + assert_eq!(page_backdrops(&doc, &all), [None; 4]); + remove_marks(&mut doc, &all, MarkKind::Background).unwrap(); + let after: Vec = pdfcraft_model::pages(&doc).iter().filter_map(|p| p.dict.get(b"Contents").cloned()).collect(); + let refs = |list: &[Object]| -> Vec> { + list.iter() + .map(|c| match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }) + .collect() + }; + assert_eq!(refs(&after), refs(&before), "every page points at its original stream again"); + assert_eq!(page_backdrops(&doc, &all), [Some(Backdrop::Fill), None, Some(Backdrop::Fill), None]); +} + +#[test] +fn a_content_stream_whose_bytes_live_in_an_external_file_is_left_alone() { + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(), + stream("/F (content.bin)", &format!("1 g 0 0 300 200 re f {TEXT}")), + ]; + let mut doc = build(&objs); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "the inline bytes are not what the page draws"); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..]); +} + +#[test] +fn the_backdrop_copy_keeps_the_original_dictionary_less_its_old_encoding() { + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(), + stream("/Vendor (keep me) /DL 9999", &format!("1 g 0 0 300 200 re f {TEXT}")), + ]; + let mut doc = build(&objs); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + let Some(Object::Array(list)) = p.dict.get(b"Contents").map(|c| (*doc.resolve(c)).clone()) else { panic!("contents") }; + let Object::Stream(copy) = &*doc.resolve(&list[1]) else { panic!("a stream") }; + assert!(copy.dict.get(b"Vendor").is_some(), "unknown entries survive"); + assert!(copy.dict.get(b"DL").is_none(), "the old decoded length no longer holds"); + assert!(copy.dict.get(b"PCBackdrop").is_some()); + assert_eq!(String::from_utf8_lossy(©.decoded().unwrap()), format!("1 g 0 0 300 200 re n {TEXT}")); +} + +#[test] +fn a_stream_listed_twice_is_replaced_in_both_slots_and_restored_in_both() { + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 4 0 R] >>".into(), + stream("", &format!("q 1 g 0 0 300 200 re f Q {TEXT}")), + ]); + let original = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let s = streams(&doc); + assert_eq!(s.len(), 3, "{s:?}"); + assert!(s[1..].iter().all(|c| c.contains("re n") && !c.contains("re f")), "no slot still paints the white fill: {s:?}"); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), original); +} + +/// #2815 review: a `gs` sets only the keys its dictionary has, so a translucent, masked, +/// blended or transferred fill stays that way through a later `gs` that leaves the key out, +/// and a transfer function that isn't the identity can turn `1 g` into any colour. +#[test] +fn graphics_state_a_later_gs_leaves_out_still_keeps_the_fill() { + for state in [ + "/Half gs /LineOnly gs", + "/Masked gs /LineOnly gs", + "/Multiply gs /LineOnly gs", + "/Invert gs /LineOnly gs", + "/Invert2 gs /LineOnly gs", + "/Invert gs", + "/Invert2 gs", + "/IdentityButTR2Invert gs", + "/Invert gs q /LineOnly gs Q", + ] { + let mut doc = page(&[&format!("{state} 1 g 0 0 300 200 re f {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "{state}"); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..], "{state}: the page's content is unchanged"); + } +} + +/// The other side of the same rule: a later `gs` that does set the key back, `TR2` naming the +/// default over a `TR` function, and `Q` restoring what `q` saved still leave a plain white fill. +#[test] +fn graphics_state_set_back_before_the_fill_still_finds_the_backdrop() { + for state in [ + "/Half gs /Full gs", + "/Masked gs /Unmasked gs", + "/Multiply gs /NormalBlend gs", + "/Invert gs /IdentityTR gs", + "/Invert2 gs /DefaultTR2 gs", + "/InvertButTR2Default gs", + "/LineOnly gs", + "q /Half gs Q", + "q /Invert gs Q", + ] { + let doc = page(&[&format!("{state} 1 g 0 0 300 200 re f {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill), "{state}"); + } +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 7c8556332b..5f615b4354 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -33,6 +33,8 @@ pub enum EditError { pub type Rgb = [f64; 3]; +pub mod backdrop; +pub use backdrop::{Backdrop, backdrops_left_off, page_backdrop, page_backdrops}; mod background; pub use background::page_background; pub mod objects; diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index 00c53ec27a..d936955184 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -173,11 +173,17 @@ pub struct Background { pub source: Option, /// Pictures: size relative to the page (1.0 fits the page). pub scale: f64, + /// Replace a white backdrop the page paints itself, which would hide the background + /// ([`backdrop`](crate::backdrop)). Removing the background turns it back on where that is + /// still safe ([`backdrops_left_off`](crate::backdrops_left_off) are the pages where it isn't). + pub replace_backdrop: bool, } impl Default for Background { + /// A light yellow that still reads as a colour on screen (a near-white default looked as if + /// nothing had happened). fn default() -> Self { - Self { color: [1.0, 1.0, 0.85], opacity: 1.0, source: None, scale: 1.0 } + Self { color: [1.0, 0.95, 0.7], opacity: 1.0, source: None, scale: 1.0, replace_backdrop: true } } } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index f5e35088b5..6dae6adf0a 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -51,6 +51,10 @@ unicode-segmentation = "1.13" # Already in the graph through lopdf; used only for the local UTC offset ("today"). The workspace # entry is std-only; this target adds `clock` for `chrono::Local::now`. chrono = { workspace = true, features = ["clock"] } +getrandom = "0.4" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] pdfcraft-testkit = { workspace = true } diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 044e40e2e2..dca1e24af5 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -141,6 +141,7 @@ pub enum OfficeFormat { Pptx, Html, Rtf, + Markdown, } impl OfficeFormat { @@ -150,6 +151,7 @@ impl OfficeFormat { OfficeFormat::Pptx => "pptx", OfficeFormat::Html => "html", OfficeFormat::Rtf => "rtf", + OfficeFormat::Markdown => "md", } } @@ -159,12 +161,13 @@ impl OfficeFormat { "pptx" => Some(OfficeFormat::Pptx), "html" | "htm" => Some(OfficeFormat::Html), "rtf" => Some(OfficeFormat::Rtf), + "md" | "markdown" => Some(OfficeFormat::Markdown), _ => None, } } } -/// A Word, PowerPoint, HTML or RTF export ([`crate::Document::export_office_checked`]). +/// A Word, PowerPoint, HTML, RTF or Markdown export ([`crate::Document::export_office_checked`]). #[derive(Clone, Debug, Default, PartialEq)] pub struct OfficeExport { /// The exported file. @@ -275,14 +278,16 @@ impl crate::Document { /// [`Self::export_office`], and the pages whose text it had to leave out /// ([`OfficeExport::truncated_pages`]). pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport { - // RTF leaves pictures out. - let (pages, truncated_pages) = self.export_pages_checked(format != OfficeFormat::Rtf, MAX_EXPORT_IMAGE_BYTES); + // RTF and Markdown leave pictures out. + let (pages, truncated_pages) = + self.export_pages_checked(!matches!(format, OfficeFormat::Rtf | OfficeFormat::Markdown), MAX_EXPORT_IMAGE_BYTES); let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string()); let bytes = match format { OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title), OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title), OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(), OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(), + OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(), }; OfficeExport { bytes, truncated_pages } } diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index eaa8f70807..fa98a18417 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -59,9 +59,52 @@ pub struct Document { /// XFA forms: what was approximated, rewritten or could not be written to the XFA data /// (also in `info.warnings`, kept there when the document is re-read). pub xfa_warnings: Vec, - /// What the last successful [`Session::apply`](crate::Session::apply) wrote that its fonts can't draw: kept as typed, - /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. - pub edit_notices: Vec, + /// What the last successful [`Session::apply`](crate::Session::apply) wants said (see + /// [`EditNotice`]): text its fonts can't draw, pages whose own backdrop stays off. The + /// interface shows it; tools return it. + pub edit_notices: Vec, +} + +/// Something the last successful edit did that the user should hear about, never left silent +/// (AGENTS.md §4). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditNotice { + /// Text stored as typed but drawn with `?`. + Undrawn(TextNotice), + /// Remove background couldn't turn back on the white backdrop these pages (0-based, + /// ascending) paint themselves: their content changed after the background replaced it, so + /// turning it on could paint over something (`pdfcraft_edit::backdrops_left_off`). They look + /// the same on white paper. + BackdropsLeftOff { pages: Vec }, +} + +impl std::fmt::Display for EditNotice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EditNotice::Undrawn(n) => n.fmt(f), + EditNotice::BackdropsLeftOff { pages } => { + let (which, own) = if pages.len() == 1 { ("page", "its") } else { ("pages", "their") }; + write!( + f, + "{which} {} changed after the background was added, so {own} own white backdrop was not turned back on (it looks the same on white paper)", + page_runs(pages) + ) + } + } + } +} + +/// 0-based pages as 1-based runs: `[0, 1, 2, 5]` is "1-3, 6". +pub(crate) fn page_runs(pages: &[usize]) -> String { + let mut runs: Vec<(usize, usize)> = Vec::new(); + for &p in pages { + match runs.last_mut() { + Some((_, end)) if end.checked_add(1) == Some(p) => *end = p, + _ => runs.push((p, p)), + } + } + let one = |p: usize| p.saturating_add(1); + runs.iter().map(|&(a, b)| if a == b { one(a).to_string() } else { format!("{}-{}", one(a), one(b)) }).collect::>().join(", ") } /// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn @@ -156,6 +199,20 @@ impl Document { self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() } + /// The backdrop `page` (0-based) paints itself, which hides a background (Add Background). + pub fn page_backdrop(&self, page: usize) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_edit::page_backdrop(&e.cos, page).ok().flatten()) + } + + /// [`Self::page_backdrop`] for each of `pages`, in order, walking the page tree once: the + /// way to check many pages. A page out of range has none. + pub fn page_backdrops(&self, pages: &[usize]) -> Vec> { + match &self.editor { + Some(e) => pdfcraft_edit::page_backdrops(&e.cos, pages), + None => vec![None; pages.len()], + } + } + /// Edit a PDF: the images `page` draws (0-based). pub fn page_images(&self, page: usize) -> Vec { self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() @@ -320,6 +377,13 @@ impl Document { self.config.hide_comments } + /// Whether `Edit::DeleteAnnotation` would delete the annotation at `(page, index)`: the + /// engine's own check (it, and every reply that would go with it, unlocked), for a Delete + /// command to ask before it is offered. + pub fn annotation_deletable(&self, page: usize, index: usize) -> bool { + self.editor.as_ref().is_some_and(|e| pdfcraft_annot::deletable(&e.cos, page, index).is_ok()) + } + pub fn can_undo(&self) -> Option<&str> { self.undo_label().map(Label::as_str) } diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 32dc7942ab..c6b1a7438b 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -177,6 +177,8 @@ pub(crate) struct EditCtx { pub(crate) today: (i64, u32, u32), pub(crate) seed: u64, pub(crate) count: u64, + /// Platform CSPRNG (`getrandom::fill`). Tests replace it so Protect can fail closed. + pub(crate) rng: fn(&mut [u8]) -> Result<(), getrandom::Error>, /// Fields the edit renamed to keep imported names separate (old → new), for the repair /// log and the undo label. pub(crate) renamed_fields: Vec<(String, String)>, @@ -209,24 +211,21 @@ impl EditCtx { today: (1970, 1, 1), seed, count: 0, + rng: getrandom::fill, renamed_fields: Vec::new(), redaction_overlays: Vec::new(), } } - /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the - /// operating system, so no extra dependency is needed. - fn entropy(&mut self) -> [u8; 32] { - use std::hash::{BuildHasher, Hasher}; + /// 32 bytes of entropy for new encryption keys, salts and generated owner passwords from the + /// platform CSPRNG. Fails closed when secure randomness is unavailable. + fn entropy(&mut self) -> Result<[u8; 32], EditError> { let mut out = [0u8; 32]; - for (i, chunk) in out.chunks_mut(8).enumerate() { - let mut h = std::collections::hash_map::RandomState::new().build_hasher(); - h.write_u64(self.seed ^ i as u64); - h.write_u64(self.count); - self.count += 1; - chunk.copy_from_slice(&h.finish().to_le_bytes()); + (self.rng)(&mut out).map_err(|e| EditError::Protection(format!("no secure randomness available: {e}")))?; + if out == [0u8; 32] { + return Err(EditError::Protection("no secure randomness available".into())); } - out + Ok(out) } /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. @@ -738,10 +737,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } Edit::Protect(p) => { p.validate()?; - let seed = cx.entropy(); + let seed = cx.entropy()?; // Without a permissions password nothing is restricted, so the owner password is a // random one nobody needs. - let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); + let random_owner = match p.permissions_password { + Some(_) => String::new(), + None => cx.entropy()?.iter().map(|b| format!("{b:02x}")).collect(), + }; let params = pdfcraft_cos::NewEncryption { algorithm: p.algorithm, user_password: p.open_password.as_deref().unwrap_or(""), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c41c0466c4..c8744c4a00 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -66,7 +66,7 @@ pub use links::{ }; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; -pub use stamp_preview::StampPreview; +pub use stamp_preview::{CommentLayer, StampPreview}; pub use text_preview::{TextEditPreview, UnmarkedPages}; pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; @@ -93,7 +93,7 @@ pub use pdfcraft_forms::{ }; pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; +pub use pdfcraft_edit::{Backdrop, BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; @@ -125,7 +125,9 @@ use bookmarks::outline_rows; pub use bookmarks::{BookmarkPage, BookmarkRow}; use comments::comment_list; pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name}; -pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; +#[cfg(test)] +use document::page_runs; +pub use document::{Document, EditNotice, TextNotice, display_bytes_for, render_password_for}; use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index f8d5648a2d..c78b10ab94 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -6,6 +6,7 @@ use super::{ render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, uses_scripts, write_incremental, xfa, xfa_sync_datasets, }; +use crate::{EditNotice, MarkKind}; impl Session { /// Apply an edit. On success the previous state is undoable and the view data is refreshed. @@ -166,13 +167,7 @@ impl Session { if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { return Err(EditError::SignedRewrite(edit.label().to_string())); } - // What this edit drew with `?` (a value or comment text its font can't draw, stored as - // typed): reported, never silent (AGENTS.md §4). - let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { - guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default() - } else { - Vec::new() - }; + let notices = edit_notices(&edit, &editor.cos, &next); let previous = std::mem::replace(&mut editor.cos, next); let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); let previous_keys = std::mem::replace(&mut editor.keys, keys); @@ -526,3 +521,21 @@ impl Session { Ok(()) } } + +/// What `edit`, taking `prev` to `next`, wants said: never silent (AGENTS.md §4). +fn edit_notices(edit: &Edit, prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { + // What it drew with `?` (a value or comment text its font can't draw, stored as typed). + let mut notices: Vec = if matches!(scope_of(edit), Scope::Form | Scope::Comments | Scope::Full) { + guard(|| undrawn_notices(prev, next)).unwrap_or_default().into_iter().map(EditNotice::Undrawn).collect() + } else { + Vec::new() + }; + // Pages whose own white backdrop Remove background couldn't turn back on. + if matches!(edit, Edit::RemoveMarks { kind: MarkKind::Background }) { + let pages = guard(|| pdfcraft_edit::backdrops_left_off(next)).unwrap_or_default(); + if !pages.is_empty() { + notices.push(EditNotice::BackdropsLeftOff { pages }); + } + } + notices +} diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 1e8cd9b08a..34d75e5c82 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -68,10 +68,12 @@ impl Session { if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } } - /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium - /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects - /// packed into compressed object streams. Returns the bytes and how many objects were - /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). + /// Reduce File Size: Acrobat's defaults (colour and grey images above 225 ppi to 150 ppi, + /// JPEG medium quality; black-and-white averaged down to 300 ppi above 450 ppi, and stored as + /// CCITT Group 4 whenever smaller; thumbnails dropped), identical resources merged, unused + /// objects dropped, objects packed into compressed object streams. Returns the bytes and how + /// many objects were merged. The open document is not changed (Acrobat saves the reduced copy + /// as a new file). pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; Ok((bytes, report.merged)) diff --git a/crates/engine/src/signature_image.rs b/crates/engine/src/signature_image.rs index daff674c19..ae8842ddfb 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -61,7 +61,7 @@ impl Document { pdfcraft_annot::props(&editor.cos, page, index).map(|p| p.opacity).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0) as f32; let turn = pdfcraft_annot::picture_rotation(&editor.cos, page, index).map_err(|e| e.to_string())?; let mut cos = editor.cos.clone(); - pdfcraft_annot::delete_annotation(&mut cos, page, index).map_err(|e| e.to_string())?; + pdfcraft_annot::lift_annotation(&mut cos, page, index).map_err(|e| e.to_string())?; let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); Ok(Some(ImageSignaturePreview { image, opacity, turn, page, bytes, config: self.config.clone() })) })? diff --git a/crates/engine/src/stamp_preview.rs b/crates/engine/src/stamp_preview.rs index 6e5758a7ce..630ec0c3f0 100644 --- a/crates/engine/src/stamp_preview.rs +++ b/crates/engine/src/stamp_preview.rs @@ -1,4 +1,5 @@ -//! A stamp while it is moved or resized: the page without it, and the stamp alone. +//! A stamp while it is moved or resized: the page without it, and the stamp alone. Any other +//! comment while it is moved: the comment alone. use std::sync::Arc; @@ -73,7 +74,7 @@ impl Document { return Ok(None); } let mut background = editor.cos.clone(); - pdfcraft_annot::delete_annotation(&mut background, page, index).map_err(|e| e.to_string())?; + pdfcraft_annot::lift_annotation(&mut background, page, index).map_err(|e| e.to_string())?; // The page beneath as it is displayed: comments without an appearance get theirs. let background = pdfcraft_annot::with_missing_appearances(&background).unwrap_or(background); let write = |cos: &pdfcraft_cos::Document| write_incremental(cos, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string()); @@ -81,3 +82,59 @@ impl Document { })? } } + +/// Comments alone on a transparent page, for drawing them where they go while they are moved. +/// The page beneath keeps showing them where they were until the move is applied: rendering the page +/// without it would cost a whole page's render at every drag on a heavy plan. Read-only, as +/// [`StampPreview`] is. +pub struct CommentLayer { + page: usize, + bytes: Arc>, + config: RenderConfig, +} + +impl CommentLayer { + /// One worker for the comments alone: their page has no content and no other annotations. + pub fn renderer(&self) -> RenderPool { + RenderPool::new(self.bytes.clone(), 1, self.transparent()) + } + + /// The comments alone, rendered synchronously with the renderer's normal pixel limits. + pub fn render(&self, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + guard(|| { + let out = + PageRenderer::new(self.bytes.clone(), self.transparent()).render(RenderRequest { page: self.page, scale, ..Default::default() }); + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + })? + } + + fn transparent(&self) -> RenderConfig { + RenderConfig { transparent: true, ..self.config.clone() } + } +} + +impl Document { + /// The comments at `indices` on `page` alone (the ones moved together), drawn as on screen: + /// one without an appearance of its own gets the one the page view draws. `None` when one + /// of them doesn't exist or none has anything to draw. + pub fn comment_layer(&self, page: usize, indices: &[usize]) -> Result, String> { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + if indices.is_empty() || !indices.iter().all(|&i| self.info.annotations.iter().any(|a| a.page == page && a.index == i)) { + return Ok(None); + } + let mut alone = pdfcraft_annot::with_missing_appearances(&editor.cos).unwrap_or_else(|| editor.cos.clone()); + if !pdfcraft_annot::isolate_annotations(&mut alone, page, indices).map_err(|e| e.to_string())? { + return Ok(None); + } + let bytes = write_incremental(&alone, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string())?; + Ok(Some(CommentLayer { page, bytes, config: self.config.clone() })) + })? + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6a68916dff..1744332365 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use super::document::is_placeholder_title; -use super::edit::check_permission; +use super::edit::{EditCtx, check_permission, run_edit}; use super::*; use pdfcraft_render::{Layer, LayerOp, OpenError, ReadingColors}; @@ -2317,6 +2317,84 @@ fn passwords_that_encryption_would_make_empty_or_equal_are_refused() { } } +#[test] +fn fresh_sessions_protecting_identical_documents_produce_different_salts() { + // Characterization only: two sessions' salts differ. A per-call RandomState already did + // that before Protect drew from the platform CSPRNG, so this does not prove the source. + // `protect_fails_closed_when_randomness_is_unavailable` is the guard. It does not compile + // against a Protect with no CSPRNG hook, and it fails closed when that hook errors or + // returns zeros. Comparing the stored function with `getrandom::fill` is not reliable: + // one function can have different addresses in different codegen units. + let raw = Arc::new(fixture(1)); + let encrypt_bytes = |saved: Arc>, key: &[u8]| -> Vec { + let doc = pdfcraft_cos::Document::open_with_password(saved, Some("secret")).unwrap(); + let enc = doc.trailer().get(b"Encrypt").map(|o| doc.resolve(o)).unwrap(); + enc.as_dict().unwrap().get(key).map(|o| doc.resolve(o)).unwrap().as_string().unwrap().bytes.clone() + }; + let protect_once = |p: Protection| -> Arc> { + let mut s = Session::new(); + let id = s.open("same.pdf", None, raw.clone(), None).unwrap(); + s.apply(id, Edit::Protect(p)).unwrap(); + s.save_bytes(id).unwrap() + }; + let a = protect_once(protection(Some("secret"), None)); + let b = protect_once(protection(Some("secret"), None)); + let (u_a, u_b) = (encrypt_bytes(a.clone(), b"U"), encrypt_bytes(b.clone(), b"U")); + let (o_a, o_b) = (encrypt_bytes(a.clone(), b"O"), encrypt_bytes(b.clone(), b"O")); + let (ue_a, ue_b) = (encrypt_bytes(a.clone(), b"UE"), encrypt_bytes(b.clone(), b"UE")); + let (oe_a, oe_b) = (encrypt_bytes(a, b"OE"), encrypt_bytes(b, b"OE")); + assert_eq!((u_a.len(), u_b.len(), o_a.len(), o_b.len()), (48, 48, 48, 48)); + assert_ne!(&u_a[32..48], &u_b[32..48], "user validation and key salts must differ across fresh sessions"); + assert_ne!(&o_a[32..48], &o_b[32..48], "owner validation and key salts must differ across fresh sessions"); + assert_ne!(ue_a, ue_b, "wrapped user file keys must differ across fresh sessions"); + assert_ne!(oe_a, oe_b, "wrapped owner file keys must differ across fresh sessions"); + + // Generated owner passwords in R4 (AES-128) must also come from CSPRNG entropy, even when the + // document already carries a fixed /ID and only an open password is set. + let mut with_id = pdfcraft_cos::Document::open(raw).unwrap(); + let fixed_id = pdfcraft_cos::Object::String(pdfcraft_cos::PdfString { bytes: b"0123456789abcdef".to_vec(), hex: true }); + with_id.trailer_mut().set(b"ID".to_vec(), pdfcraft_cos::Object::Array(vec![fixed_id.clone(), fixed_id])); + let raw_with_id = Arc::new(pdfcraft_cos::write_full(&with_id, &Default::default()).unwrap()); + let protect_r4 = || -> Vec { + let mut s = Session::new(); + let id = s.open("same.pdf", None, raw_with_id.clone(), None).unwrap(); + let p = Protection { algorithm: pdfcraft_cos::Algorithm::Aes128, ..protection(Some("secret"), None) }; + s.apply(id, Edit::Protect(p)).unwrap(); + encrypt_bytes(s.save_bytes(id).unwrap(), b"O") + }; + assert_ne!(protect_r4(), protect_r4(), "generated owner passwords must differ across fresh sessions"); +} + +#[test] +fn protect_fails_closed_when_randomness_is_unavailable() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static CALLS: AtomicUsize = AtomicUsize::new(0); + fn fail_rng(_: &mut [u8]) -> Result<(), getrandom::Error> { + Err(getrandom::Error::UNSUPPORTED) + } + fn zero_rng(out: &mut [u8]) -> Result<(), getrandom::Error> { + out.fill(0); + Ok(()) + } + fn fail_second_rng(out: &mut [u8]) -> Result<(), getrandom::Error> { + if CALLS.fetch_add(1, Ordering::SeqCst) == 0 { + out.fill(0xA5); + Ok(()) + } else { + Err(getrandom::Error::UNSUPPORTED) + } + } + CALLS.store(0, Ordering::SeqCst); + for rng in [fail_rng, zero_rng, fail_second_rng] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let mut cx = EditCtx::new(None, 0); + cx.rng = rng; + let err = run_edit(&mut doc, &Edit::Protect(protection(Some("secret"), None)), &mut cx); + assert!(matches!(err, Err(EditError::Protection(_))), "Protect must fail closed when CSPRNG fails: {err:?}"); + assert!(doc.output_handler().is_none(), "document must stay unprotected when CSPRNG fails"); + } +} + /// A one-page form: a text field and a check box (with appearance states). fn form_fixture() -> Vec { let objs = [ @@ -2522,10 +2600,10 @@ fn a_value_its_font_cannot_draw_is_reported_by_the_edit_that_drew_it() { fill(&mut s, "name", FieldValue::Text("张三".into())).unwrap(); let d = s.get(id).unwrap(); assert_eq!(d.form[0].value, ["张三"]); - let n = &d.edit_notices; - assert_eq!(n.len(), 1, "{n:?}"); - assert_eq!((n[0].field.as_deref(), n[0].page, n[0].ch, n[0].redrawn_elsewhere), (Some("name"), 0, '张', true)); - assert!(n[0].to_string().contains("\"张\" (U+5F20)") && n[0].to_string().contains("other PDF viewers redraw"), "{}", n[0]); + let [EditNotice::Undrawn(n)] = d.edit_notices.as_slice() else { panic!("{:?}", d.edit_notices) }; + assert_eq!((n.field.as_deref(), n.page, n.ch, n.redrawn_elsewhere), (Some("name"), 0, '张', true)); + let said = d.edit_notices[0].to_string(); + assert!(said.contains("\"张\" (U+5F20)") && said.contains("other PDF viewers redraw"), "{said}"); fill(&mut s, "ok", FieldValue::Check(true)).unwrap(); assert!(s.get(id).unwrap().edit_notices.is_empty(), "an unrelated edit reports nothing"); fill(&mut s, "name", FieldValue::Text("李四".into())).unwrap(); @@ -3149,6 +3227,59 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background")); } +/// One 100 × 100 page under a background (`/PCMark /Background`), its own content painting a +/// white backdrop first. With `copy`, that content is a backdrop copy whose tag is `copy`. +fn backdrop_pdf(copy: Option<&str>) -> Vec { + let bg = "q 1 0 0 rg 0 0 100 100 re f Q"; + let (tag, fill) = match copy { + Some(tag) => (format!("/PCBackdrop {tag} "), "n"), + None => (String::new(), "f"), + }; + let content = format!("1 g 0 0 100 100 re {fill} 0 g BT /F1 12 Tf 10 50 Td (Hi) Tj ET"); + format!( + "%PDF-1.7\n\ + 1 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 100 100] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >> endobj\n\ + 4 0 obj << /PCMark /Background /Length {} >> stream\n{bg}\nendstream endobj\n\ + 5 0 obj << {tag}/Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + bg.len(), + content.len() + ) + .into_bytes() +} + +/// Remove background says which pages keep their own white backdrop off (it couldn't be turned +/// back on safely), and says nothing when every backdrop comes back. +#[test] +fn remove_background_names_the_pages_whose_own_backdrop_stays_off() { + let mut s = Session::new(); + // A tag whose hash doesn't match the bytes before the fill: something changed them since. + let tag = format!("<< /Index 2 /Op (f) /Before <{}> >>", "00".repeat(32)); + let id = s.open("a.pdf", None, Arc::new(backdrop_pdf(Some(&tag))), None).unwrap(); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.edit_notices, [EditNotice::BackdropsLeftOff { pages: vec![0] }]); + let said = d.edit_notices[0].to_string(); + assert!(said.starts_with("page 1 changed after the background was added"), "{said}"); + // An unrelated edit after it says nothing. + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + assert!(s.get(id).unwrap().edit_notices.is_empty()); + + // Added and removed here: the backdrop comes back, and nothing is said. + let id = s.open("b.pdf", None, Arc::new(backdrop_pdf(None)), None).unwrap(); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + let background = Background { color: [1.0, 0.0, 0.0], ..Background::default() }; + s.apply(id, Edit::AddBackground { pages: vec![0], settings: background, replace: false, file: None }).unwrap(); + assert_eq!(s.get(id).unwrap().page_backdrops(&[0]), [None], "replaced"); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + assert!(s.get(id).unwrap().edit_notices.is_empty()); + assert_eq!(s.get(id).unwrap().page_backdrops(&[0]), [Some(Backdrop::Fill)], "turned back on"); + assert_eq!(page_runs(&[0, 1, 2, 5, 7, 8]), "1-3, 6, 8-9"); + assert_eq!(page_runs(&[]), ""); +} + /// Update previews its marks on the page without the ones it replaces: a yellow background /// updated to blue isn't previewed as blue over yellow, nor a new watermark over the old one. /// The other kinds of mark and the page's own content stay, every page is prepared at once (the @@ -3431,6 +3562,24 @@ fn comment_text(s: &Session, id: DocId) -> Vec { t } +/// A stamp whose reply is locked can still be dragged, so its layers are still prepared: the page +/// beneath is drawn on a copy, where locks don't count. Deleting the stamp is what they refuse. +#[test] +fn a_stamp_with_a_locked_reply_still_has_its_drag_layers() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 0, text: "Approved".into(), author: "Bob".into() }).unwrap(); + let reply = s.get(id).unwrap().info.annotations.iter().find(|a| a.parent == Some((0, 0))).expect("the reply").index; + s.apply(id, Edit::LockAnnotation { page: 0, index: reply, locked: true }).unwrap(); + let doc = s.get(id).unwrap(); + let background = doc.stamp_preview(0, 0).unwrap().expect("the line is a stamp").render_background(1.0).unwrap(); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + assert!((30..=130).all(|x| px(&background, x, 63) == [255, 255, 255, 255]), "the page beneath has no line"); + assert!(s.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).is_err(), "the locked reply keeps the stamp"); +} + /// A dragged stamp's layers: the page without it (its text and other comments kept) and the /// stamp alone on a transparent page. Preparing them changes nothing in the document. #[test] @@ -3485,6 +3634,44 @@ trailer << /Root 1 0 R >> assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a stamp without an appearance"); } +/// A moved comment's layer: the comment alone on a transparent page (no page text, no other +/// comment), drawn as on screen, another app's comment without an appearance too. Preparing it +/// changes nothing in the document. +#[test] +fn comment_layer_draws_the_comment_alone() { + let (mut s, id) = session_with(1); + s.apply(id, rect_comment(0, [140.0, 40.0, 190.0, 90.0])).unwrap(); + s.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap(); + let doc = s.get(id).unwrap(); + let (generation, dirty) = (doc.edit_generation(), doc.dirty); + let layer = doc.comment_layer(0, &[0]).unwrap().expect("a rectangle").render(1.0).unwrap(); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + assert_eq!(px(&layer, 165, 65), [255, 0, 0, 255], "the rectangle is drawn"); + let near = |x: u32, y: u32| (137..=193).contains(&x) && (37..=93).contains(&y); + let stray: Vec<(u32, u32)> = + (1..300).flat_map(|y| (0..200).map(move |x| (x, y))).filter(|&(x, y)| !near(x, y) && px(&layer, x, y) != [0, 0, 0, 0]).collect(); + assert!(stray.is_empty(), "only the rectangle is on its layer: {:?}", &stray[..stray.len().min(8)]); + assert!(doc.comment_layer(0, &[9]).unwrap().is_none(), "no such comment"); + let both = doc.comment_layer(0, &[0, 1]).unwrap().expect("both").render(1.0).unwrap(); + assert_eq!((px(&both, 165, 65), px(&both, 40, 40)), ([255, 0, 0, 255], [255, 0, 0, 255]), "moved together, drawn together"); + let doc = s.get(id).unwrap(); + assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add rectangle")), "the document is unchanged"); + + let bare = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Square /Rect [140 40 190 90] /C [1 0 0] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new(); + let id = s.open("bare.pdf", None, Arc::new(bare.to_vec()), None).unwrap(); + let layer = s.get(id).unwrap().comment_layer(0, &[0]).unwrap().expect("drawn as on screen").render(1.0).unwrap(); + assert!((138..=142).any(|x| px(&layer, x, 65)[..3] == [255, 0, 0]), "the square without an appearance is drawn"); +} + #[test] fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() { let (mut s, id) = session_with(1); @@ -8347,11 +8534,11 @@ fn rolls_back_metadata_scope(s: &mut Session, id: DocId) { // 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`. s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap(); let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false }; - s.doc_mut(id).unwrap().edit_notices.push(notice.clone()); + s.doc_mut(id).unwrap().edit_notices.push(EditNotice::Undrawn(notice.clone())); panic_on_refresh(1); assert_eq!(s.undo(id), Err(EditError::Reopen("injected refresh panic".into()))); assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft")); - assert_eq!(s.get(id).unwrap().edit_notices, [notice]); + assert_eq!(s.get(id).unwrap().edit_notices, [EditNotice::Undrawn(notice)]); assert_eq!(s.undo(id).unwrap().as_str(), "Change Title"); assert_eq!(s.get(id).unwrap().info.title, None); assert!(s.get(id).unwrap().edit_notices.is_empty()); @@ -9024,69 +9211,69 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec { bytes } -/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its -/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the -/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked -/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without -/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. -#[test] -fn need_appearances_buttons_are_displayed_from_their_captions() { - let pdf = |need: bool| { - let flag = if need { "/NeedAppearances true " } else { "" }; - format!( - "%PDF-1.7\n\ - 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 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 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ - 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ - 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ) - .into_bytes() - }; - let render = |bytes: Arc>| { - let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); - r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) - }; - // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. - let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { - let (px, _) = page.rgba.as_chunks::<4>(); - (200 - y1..200 - y0) - .flat_map(|y| (x0..x1).map(move |x| (x, y))) - .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) - .count() - }; - // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside - // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). - let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); - let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); - let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); - let mut s = Session::new(); - let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; - let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); - let printed = render(Arc::new(pictures)); - for (page, how) in [(&shown, "shown"), (&printed, "printed")] { - let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); - assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); - assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); - assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); - assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); - let dot = ink(page, (188, 108, 212, 132)); - assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); - } - let saved = s.save_bytes(id).unwrap(); - assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); - - // Without /NeedAppearances the file's filled squares are shown. - let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - for x in [20, 100, 180] { - assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); - } -} +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 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 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} diff --git a/crates/export/src/as_markdown.rs b/crates/export/src/as_markdown.rs new file mode 100644 index 0000000000..a5c986c6b3 --- /dev/null +++ b/crates/export/src/as_markdown.rs @@ -0,0 +1,151 @@ +use super::as_html::allowed; +use super::layout::{Cell, Item, MAX_COLS, Page, items}; + +/// Markdown's deepest heading. +const MAX_HEADING: u8 = 6; + +/// `s` as Markdown text on one line: line breaks become spaces, control characters are dropped, +/// and every character Markdown would read as markup is escaped with a backslash. +fn md_text(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars().filter(|c| allowed(*c)) { + match c { + '\n' | '\r' | '\t' => o.push(' '), + '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '#' | '|' | '~' | '!' | '&' => { + o.push('\\'); + o.push(c); + } + c => o.push(c), + } + } + o +} + +/// `line` with its start escaped where Markdown would read a list item there (`- `, `+ `, +/// `1. `, `1) `); the other block markers (`#`, `>`) are escaped by [`md_text`] already. +fn block_start(line: String) -> String { + let digits = line.bytes().take_while(u8::is_ascii_digit).count(); + match line.as_bytes().get(digits) { + Some(b'.' | b')') if digits > 0 => format!("{}\\{}", line.get(..digits).unwrap_or_default(), line.get(digits..).unwrap_or_default()), + Some(b'-' | b'+') if digits == 0 => format!("\\{line}"), + _ => line, + } +} + +/// `text` (already escaped) in bold and/or italic, its outer spaces kept outside. `open` and +/// `close` say whether `*` markers would be read as emphasis on that side (see [`marks`]); +/// where they would not, as in `foo` + bold `.` + `bar`, the run is wrapped in HTML tags, which +/// CommonMark takes inline. The text's own outer spaces count as a side that marks. +pub(crate) fn emphasis(text: &str, bold: bool, italic: bool, open: bool, close: bool) -> String { + let core = text.trim(); + if core.is_empty() || !(bold || italic) { + return text.to_string(); + } + let lead = text.get(..text.len().saturating_sub(text.trim_start().len())).unwrap_or_default(); + let trail = text.get(text.trim_end().len()..).unwrap_or_default(); + let markers = (open || !lead.is_empty()) && (close || !trail.is_empty()); + let (before, after) = match (markers, bold, italic) { + (true, true, true) => ("***", "***"), + (true, true, false) => ("**", "**"), + (true, false, _) => ("*", "*"), + (false, true, true) => ("", ""), + (false, true, false) => ("", ""), + (false, false, _) => ("", ""), + }; + format!("{lead}{before}{core}{after}{trail}") +} + +/// A paragraph's runs (text, bold, italic) as one line of Markdown. Neighbouring runs in the +/// same style are joined first, so two bold runs never meet as `****`. +pub(crate) fn paragraph(runs: &[(&str, bool, bool)]) -> String { + let mut merged: Vec<(String, bool, bool)> = Vec::new(); + for &(text, bold, italic) in runs { + match merged.last_mut() { + Some(last) if last.1 == bold && last.2 == italic => last.0.push_str(text), + _ => merged.push((text.to_string(), bold, italic)), + } + } + let texts: Vec = merged.iter().map(|(text, ..)| md_text(text)).collect(); + let mut out = String::new(); + for (k, ((_, bold, italic), text)) in merged.iter().zip(&texts).enumerate() { + let core = text.trim(); + let open = marks(core.chars().next(), out.chars().last()); + // Markers of a styled neighbour right next to these would join them into one run. + let close = match (merged.get(k.saturating_add(1)), texts.get(k.saturating_add(1))) { + (Some((_, b, i)), Some(next)) if (*b || *i) && !next.starts_with(char::is_whitespace) => false, + (_, next) => marks(core.chars().last(), next.and_then(|n| n.chars().next())), + }; + out.push_str(&emphasis(text, *bold, *italic, open, close)); + } + out +} + +/// Whether a `*` marker between the text's `edge` character and the `outside` one is read as +/// emphasis (CommonMark's flanking rules for `*`): always next to a space or the line's edge, +/// never next to another `*`, and otherwise unless the text's edge is punctuation while the +/// outside is a letter or digit. +fn marks(edge: Option, outside: Option) -> bool { + match (edge, outside) { + (_, None) => true, + (_, Some(o)) if o.is_whitespace() => true, + (_, Some('*')) => false, + (Some(e), Some(o)) => e.is_alphanumeric() || o.is_ascii_punctuation(), + (None, Some(_)) => false, + } +} + +fn cell(c: &Cell) -> String { + // A cell sits between `| ` and ` |`, so its markers always have a space outside. + emphasis(&md_text(&c.text), c.bold, c.italic, true, true) +} + +/// Markdown (CommonMark, with GitHub's pipe tables): headings, paragraphs with bold and italic, +/// tables with their first row as the header, and a rule between pages. Images are left out. +pub fn markdown(pages: &[Page]) -> String { + let mut blocks: Vec = Vec::new(); + for it in items(pages) { + match it { + Item::Para(runs, lvl) => { + // A heading's weight is the heading's own, as in the HTML export. + let runs: Vec<(&str, bool, bool)> = runs.iter().map(|r| (r.text.as_str(), r.block.bold && lvl == 0, r.block.italic)).collect(); + let text = paragraph(&runs); + let text = text.trim(); + if text.is_empty() { + continue; + } + blocks.push(match lvl { + 0 => block_start(text.to_string()), + l => format!("{} {text}", "#".repeat(usize::from(l.min(MAX_HEADING)))), + }); + } + Item::Table(t) => { + // Each row as wide as the widest; a cell spanning columns is followed by empty ones. + // Detected tables have at most MAX_COLS columns, and a span never counts for more. + let span = |c: &Cell| c.span.clamp(1, MAX_COLS); + let width = t.rows.iter().map(|r| r.iter().map(span).fold(0usize, usize::saturating_add)).max().unwrap_or(0); + if width == 0 { + continue; + } + let mut lines = Vec::with_capacity(t.rows.len().saturating_add(1)); + for (i, row) in t.rows.iter().enumerate() { + let mut cells: Vec = Vec::with_capacity(width); + for c in row { + cells.push(cell(c)); + cells.extend(std::iter::repeat_n(String::new(), span(c).saturating_sub(1))); + } + cells.resize(width, String::new()); + lines.push(format!("| {} |", cells.join(" | "))); + if i == 0 { + lines.push(format!("|{}", " --- |".repeat(width))); + } + } + blocks.push(lines.join("\n")); + } + Item::Img(_) => {} + Item::PageBreak => blocks.push("---".into()), + } + } + let mut s = blocks.join("\n\n"); + s.push('\n'); + s +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 5275b02439..db53ee59c8 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -1,4 +1,4 @@ -//! pdfcraft-export — Export a PDF ▸ Word, HTML, RTF (L4). +//! pdfcraft-export — Export a PDF ▸ Word, HTML, RTF, Markdown (L4). //! //! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and //! images (encoded bytes and box), in reading order. The writers turn that into a flowing @@ -18,6 +18,7 @@ pub use zip::Zip; mod as_docx; mod as_html; +mod as_markdown; mod as_rtf; mod layout; @@ -29,6 +30,9 @@ pub(crate) use as_html::esc; pub use as_html::html; #[cfg(test)] use as_html::{allowed, base64}; +pub use as_markdown::markdown; +#[cfg(test)] +use as_markdown::{emphasis, paragraph}; pub use as_rtf::rtf; pub use layout::{Block, Cell, Image, Page, Table}; #[cfg(test)] diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index d735eb4c20..27cd8264e0 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -769,3 +769,75 @@ fn a_paragraph_is_a_heading_only_when_its_text_is() { assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}"); assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}"); } + +/// #1595: Markdown gets the headings, paragraphs and a rule between pages, in reading order; +/// images are left out. +#[test] +fn markdown_has_headings_paragraphs_and_page_rules_in_order() { + let md = markdown(&[page(), page()]); + let order: Vec = ["# Annual Report\n\n", "## Overview\n\n", "Some \\ text \\& more", "Body text after the picture", "\n\n---\n\n"] + .iter() + .map(|x| md.find(x).unwrap_or_else(|| panic!("{x:?} missing in {md}"))) + .collect(); + assert!(order.windows(2).all(|w| w[0] < w[1]), "{md}"); + assert_eq!(md.matches("\n---\n").count(), 1, "one rule between two pages: {md}"); + assert_eq!(md.matches("# Annual Report").count(), 2, "{md}"); + assert!(!md.contains("PNG") && !md.contains("!["), "images are left out: {md}"); + assert!(md.ends_with(".\n") && !md.ends_with("\n\n"), "{md:?}"); +} + +/// #1595: text Markdown would read as markup stays text, emphasis wraps a run without its outer +/// spaces, and a detected table becomes a pipe table with its first row as the header. +#[test] +fn markdown_escapes_markup_and_keeps_emphasis_and_tables() { + let b = |t: &str, y: f64, bold: bool, italic: bool| Block { + text: t.into(), + rect: [72.0, y, 500.0, y + 11.0], + size: 11.0, + bold, + italic, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let p = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + b("- not a list item, and long enough to make eleven points the body size here", 700.0, false, false), + b("1. not a numbered item either", 650.0, false, false), + b("*stars* _under_ `tick` [link](x) a|b # #tag\u{1}\nnext", 600.0, false, false), + b("Strong words in a sentence of their own.", 550.0, true, false), + b("slanted", 500.0, false, true), + ], + images: Vec::new(), + background: None, + }; + let md = markdown(&[p]); + assert!(md.contains("\\- not a list item"), "{md}"); + assert!(md.contains("1\\. not a numbered item"), "{md}"); + assert!(md.contains("\\*stars\\* \\_under\\_ \\`tick\\` \\[link\\](x) a\\|b \\# \\#tag next"), "{md}"); + assert!(md.contains("**Strong words in a sentence of their own.**"), "{md}"); + // Markdown reads no emphasis where a marker sits next to a space: the outer spaces go outside. + assert_eq!(emphasis(" two words ", true, false, false, false), " **two words** "); + assert_eq!(emphasis("both", true, true, true, true), "***both***"); + assert_eq!(emphasis(" ", true, true, true, true), " ", "nothing to mark"); + // Runs in one style are one: two bold runs side by side don't meet as `****`. + assert_eq!(paragraph(&[("hello", true, false), ("world", true, false)]), "**helloworld**"); + // Markers inside a word would stay literal in CommonMark, so a run there gets HTML tags. + assert_eq!(paragraph(&[("foo", false, false), (".", true, false), ("bar", false, false)]), "foo.bar"); + assert_eq!(paragraph(&[("x", true, false), ("y", false, true)]), "x*y*", "two styles side by side"); + // `*` may sit inside a word, and before punctuation that follows a letter. + assert_eq!(paragraph(&[("foo", false, false), ("bar", true, false), ("baz", false, false)]), "foo**bar**baz"); + assert_eq!(paragraph(&[("a ", false, false), ("b", true, false), (" c", false, false)]), "a **b** c"); + assert_eq!(paragraph(&[("say ", false, false), ("this", false, true), (", then", false, false)]), "say *this*, then"); + assert!(md.contains("*slanted*"), "{md}"); + assert!(!md.chars().any(|c| !allowed(c)), "{md:?}"); + + let md = markdown(&[table_page()]); + let rows: Vec<&str> = md.lines().filter(|l| l.starts_with('|')).collect(); + assert_eq!(rows.first(), Some(&"| Meter | Unit | Reading |"), "{md}"); + assert_eq!(rows.get(1), Some(&"| --- | --- | --- |"), "{md}"); + assert!(rows.contains(&"| A1 | kWh | 37414.00 |"), "{md}"); + assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}"); +} diff --git a/crates/filters/Cargo.toml b/crates/filters/Cargo.toml index 9d2ae34b94..fd63ac2b0b 100644 --- a/crates/filters/Cargo.toml +++ b/crates/filters/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pdfcraft-filters" -description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength" +description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength, CCITT Group 4 encoding and Group 3 and 4 decoding" version.workspace = true edition.workspace = true license.workspace = true @@ -11,6 +11,9 @@ repository.workspace = true workspace = true [dependencies] +fax.workspace = true +# CCITT fax decoding (`decode_ccitt`); already in the tree through hayro. +hayro-ccitt.workspace = true flate2.workspace = true thiserror.workspace = true diff --git a/crates/filters/README.md b/crates/filters/README.md index 959180d87a..b8c1d22815 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -3,7 +3,7 @@ PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`. - **Layer:** L0, standalone. No dependency on any other PdfCraft crate. `cos` depends on it (architecture §3 rule 3). -- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). +- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's (MIT OR Apache-2.0, already in the tree through hayro), bounded here. - `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`. ## API @@ -22,6 +22,9 @@ decode_tolerant(chain, data, max_output) -> Result<(Vec, bool /*partial*/), decode_tolerant_counted(chain, data, max_output) -> Result<(Vec, bool /*partial*/, usize /*decoded*/), (FilterError, usize /*decoded*/)> encode(filter, params, data) -> Result, FilterError> encode_flate(data) -> Vec // zlib, level 6 +encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS +pub struct CcittParams { k, columns, rows, end_of_line, encoded_byte_align, end_of_block, black_is_1 } // Table 11; Default = its defaults +decode_ccitt(data, ¶ms, max_rows) -> Result, FilterError> // CCITT fax as CCITTFaxDecode gives it; columns <= MAX_CCITT_COLUMNS ``` - `chain` is applied first to last, as in a `/Filter` array. @@ -40,14 +43,16 @@ encode_flate(data) -> Vec // zlib, level 6 | ASCIIHexDecode | white space ignored, odd digit padded with 0, `>` EOD (optional), other characters → Corrupt | upper case, newline every 64 digits, `>` | | ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` | | RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 | +| CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt` decodes Group 3 and Group 4 on its own, as rows of 1-bit samples each on a new byte, black 0 unless `black_is_1`, at most `max_rows` rows (fewer with `/Rows`), the rows before any damage, up to 1,048,576 samples a row; Group 3 rows on bytes are read by their end-of-line codes when the data starts with one and on bytes otherwise, whatever `end_of_line` says, so data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at `k` 0 may come out as other rows (`pdfcraft_cos::Document::decoded_image` calls it for a fax image, after the filters before it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | ## Tests - Unit tests per filter with hand-computed vectors (the ISO 32000-2 LZW example, RFC 1951 stored blocks, PNG/TIFF rows). - LZW is cross-checked against `weezl` (dev-dependency) in both directions for both early-change settings. - `tests/proptests.rs`: encode→decode round trips for every encodable filter × predictor × colors × bpc × columns, two-filter chains, output-limit checks, and fuzz-style no-panic tests (random bytes, random/nonsense params, bit-flipped valid streams). +- `ccitt.rs`: `decode_ccitt` gives back the rows `encode_g4` was given (with `/BlackIs1`, `/Rows` and a row limit), refuses bad widths, and bounds its output for any input; byte-aligned Group 3 in each layout, over 32 seeded pictures, decodes to its picture, and a byte short to fewer rows. Group 4 output decoded by `hayro-ccitt` (an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status -M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2, CCITT G3/G4 decode and G4 encode, DCT encode) and Flate encode levels other than 6. +M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2, DCT encode), CCITT Group 3 encoding and Flate encode levels other than 6. diff --git a/crates/filters/src/ccitt.rs b/crates/filters/src/ccitt.rs new file mode 100644 index 0000000000..e87a82fe16 --- /dev/null +++ b/crates/filters/src/ccitt.rs @@ -0,0 +1,486 @@ +//! CCITT fax for `CCITTFaxDecode` (ISO 32000-2 §7.4.6): Group 4 (ITU-T T.6) encoding, with `/K -1`, +//! and decoding of Group 3 (T.4) and Group 4. The coding is the `fax` crate's and the decoding the +//! `hayro-ccitt` crate's; this module checks what they are given and bounds what they give. + +use fax::encoder::Encoder; +use fax::{Color, VecWriter}; + +use crate::FilterError; + +/// The widest row [`encode_g4`] encodes. The encoder counts the positions in a row in 16 bits +/// and adds two of them, so a row stays within half that range. +pub const MAX_G4_COLUMNS: u32 = 32_767; + +/// `rows` rows of `columns` 1-bit samples, most significant bit first, each row starting on a new +/// byte (as a PDF image's samples are packed), encoded as CCITT Group 4 with an end-of-block mark. +/// +/// A 0 bit is coded black and a 1 bit white, so the stream decodes to the same bits with +/// `/BlackIs1 false`, the default: an image keeps its colour space and decode array. Bytes after +/// the last row are ignored, as are the bits that pad a row to a whole byte. +/// +/// Errors: no rows or columns, or rows wider than [`MAX_G4_COLUMNS`] ([`FilterError::Unsupported`]); +/// fewer bytes than the rows need ([`FilterError::Corrupt`]). +pub fn encode_g4(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { + let width = match u16::try_from(columns) { + Ok(w) if w > 0 && columns <= MAX_G4_COLUMNS => w, + _ => return Err(FilterError::Unsupported(format!("CCITT Group 4 encoding of rows {columns} samples wide"))), + }; + if rows == 0 { + return Err(FilterError::Unsupported("CCITT Group 4 encoding of an image without rows".into())); + } + let stride = usize::from(width).div_ceil(8); + let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)).ok_or_else(|| FilterError::Corrupt { + filter: "CCITTFaxDecode", + detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()), + })?; + let mut encoder = Encoder::new(VecWriter::new()); + for row in samples.chunks_exact(stride) { + let pels = (0..usize::from(width)).map(|x| { + let white = row.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + if white { Color::White } else { Color::Black } + }); + // `VecWriter` writes to memory: its error type has no values. + if let Err(never) = encoder.encode_line(pels, width) { + match never {} + } + } + match encoder.finish() { + Ok(writer) => Ok(writer.finish()), + Err(never) => match never {}, + } +} + +/// The widest row [`decode_ccitt`] decodes: its row buffer stays within 128 KiB. +pub const MAX_CCITT_COLUMNS: u32 = 1 << 20; + +/// A `CCITTFaxDecode` stream's `/DecodeParms` (ISO 32000-2 Table 11). [`Default`] gives the +/// table's defaults. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CcittParams { + /// `/K`: negative for Group 4, 0 for Group 3 one-dimensional, positive for Group 3 mixed. + pub k: i64, + pub columns: i64, + /// `/Rows`: 0 when not given. + pub rows: i64, + pub end_of_line: bool, + pub encoded_byte_align: bool, + pub end_of_block: bool, + pub black_is_1: bool, +} + +impl Default for CcittParams { + fn default() -> Self { + CcittParams { k: 0, columns: 1728, rows: 0, end_of_line: false, encoded_byte_align: false, end_of_block: true, black_is_1: false } + } +} + +/// CCITT fax `data` decoded as `CCITTFaxDecode` gives it: rows of `columns` 1-bit samples, most +/// significant bit first, each starting on a new byte, with black as 0 (as 1 with `black_is_1`). +/// +/// At most `max_rows` rows are decoded (fewer when `rows` says so), so the output is bounded by +/// the caller. Damaged data gives the rows decoded before the damage, as viewers show them: a +/// caller that needs a number of rows checks it got them. +/// +/// Group 3 rows on bytes (`k` from 0, `encoded_byte_align`) are read by their end-of-line codes, +/// whether the fill starts each code on a byte or ends it on one, when `data` starts with one (T.4 +/// sends one before the first row when it sends them at all), and on bytes otherwise, whatever +/// `end_of_line` says. Group 4 is read as its parameters say. Data with codes but none before the +/// first row is read on bytes, as PdfCraft's renderer draws it; with `k` 0 and the fill ending each +/// code on a byte, that may be other rows than were coded. +/// +/// Errors: `columns` that aren't from 1 to [`MAX_CCITT_COLUMNS`] ([`FilterError::Unsupported`]). +pub fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result, FilterError> { + let columns = u32::try_from(p.columns) + .ok() + .filter(|c| (1..=MAX_CCITT_COLUMNS).contains(c)) + .ok_or_else(|| FilterError::Unsupported(format!("CCITT fax rows {} samples wide", p.columns)))?; + let rows = u32::try_from(p.rows).ok().filter(|r| *r > 0).map_or(max_rows, |r| r.min(max_rows)); + if rows == 0 { + return Ok(Vec::new()); + } + let settings = hayro_ccitt::DecodeSettings { + columns, + rows, + end_of_block: p.end_of_block, + end_of_line: p.end_of_line, + // Group 3 rows on bytes may have end-of-line codes whatever `end_of_line` says (the filter + // always accepts them), with the fill starting each code on a byte or ending it on one (T.4's + // fill, as TIFF writes it). Read unaligned, the codes mark the rows either way; without + // codes, the rows are read on bytes. A reading that comes up short isn't tried the other + // way: data cut short would then read as other rows. + rows_are_byte_aligned: p.encoded_byte_align && (p.k < 0 || !starts_with_end_of_line(data)), + encoding: match p.k { + ..0 => hayro_ccitt::EncodingMode::Group4, + 0 => hayro_ccitt::EncodingMode::Group3_1D, + k => hayro_ccitt::EncodingMode::Group3_2D { k: u32::try_from(k).unwrap_or(u32::MAX) }, + }, + invert_black: false, + }; + // u32 to usize: wasm32 and up have at least 32-bit usize. + let mut out = Rows::new(columns as usize, rows as usize, p.black_is_1); + // The rows decoded before damaged data are kept. + let _ = hayro_ccitt::decode(data, &mut out, &mut hayro_ccitt::DecoderContext::new(settings)); + Ok(out.out) +} + +/// Whether CCITT fax `data` starts with an end-of-line code: a 1 after 11 zeros or more, fill +/// included. T.4 sends one before the first row when it sends them at all, and no other code starts +/// with more than eight zeros. +fn starts_with_end_of_line(data: &[u8]) -> bool { + let Some((i, b)) = data.iter().enumerate().find(|(_, b)| **b != 0) else { return false }; + i.saturating_mul(8).saturating_add(b.leading_zeros() as usize) >= 11 +} + +/// Rows of 1-bit samples filled from a decoder's pixels: `columns` a row, each row on a new byte, at +/// most `rows` rows. Pixels past a row's end and rows past the last are dropped. +struct Rows { + out: Vec, + row: Vec, + x: usize, + columns: usize, + rows: usize, + done: usize, + black_is_1: bool, +} + +impl Rows { + fn new(columns: usize, rows: usize, black_is_1: bool) -> Self { + Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0, black_is_1 } + } + + /// `count` pixels, white or black. + fn push(&mut self, white: bool, count: usize) { + let end = self.x.saturating_add(count); + if white != self.black_is_1 { + for x in self.x..end.min(self.columns) { + if let Some(b) = self.row.get_mut(x / 8) { + *b |= 0x80 >> (x % 8); + } + } + } + self.x = end; + } +} + +impl hayro_ccitt::Decoder for Rows { + fn push_pixel(&mut self, white: bool) { + self.push(white, 1); + } + fn push_pixel_chunk(&mut self, white: bool, chunks: u32) { + self.push(white, (chunks as usize).saturating_mul(8)); + } + fn next_line(&mut self) { + if self.done < self.rows { + self.out.extend_from_slice(&self.row); + self.done += 1; + } + self.row.fill(0); + self.x = 0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The rows decoded by an independent decoder (`hayro-ccitt`), as 1 for white and 0 for black. + pub(crate) fn decode(data: &[u8], columns: u32, rows: u32) -> Vec> { + struct Rows(Vec>, Vec, usize); + impl hayro_ccitt::Decoder for Rows { + fn push_pixel(&mut self, white: bool) { + self.1.push(white); + } + fn push_pixel_chunk(&mut self, white: bool, chunks: u32) { + self.1.extend(std::iter::repeat_n(white, chunks as usize * 8)); + } + fn next_line(&mut self) { + let mut row = std::mem::take(&mut self.1); + row.truncate(self.2); + self.0.push(row); + } + } + let settings = hayro_ccitt::DecodeSettings { + columns, + rows, + end_of_block: true, + end_of_line: false, + rows_are_byte_aligned: false, + encoding: hayro_ccitt::EncodingMode::Group4, + invert_black: false, + }; + let mut out = Rows(Vec::new(), Vec::new(), columns as usize); + hayro_ccitt::decode(data, &mut out, &mut hayro_ccitt::DecoderContext::new(settings)).unwrap(); + out.0 + } + + /// The rows of packed samples `data`, as 1 for a 1 bit. + pub(crate) fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec> { + let stride = (columns as usize).div_ceil(8); + (0..rows as usize).map(|y| (0..columns as usize).map(|x| data[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect()).collect() + } + + #[test] + fn a_page_of_text_like_marks_round_trips_and_shrinks() { + let (w, h) = (1700u32, 220u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + // Short black strokes in lines, like glyphs on a white page. + for y in (20..h as usize).step_by(40) { + for dy in 0..12 { + for x in (100..1600).step_by(9) { + if (x / 9 + dy) % 5 != 0 { + data[(y + dy) * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + } + let g4 = encode_g4(&data, w, h).unwrap(); + assert_eq!(decode(&g4, w, h), unpack(&data, w, h)); + assert!(g4.len() * 4 < data.len(), "{} bytes from {}", g4.len(), data.len()); + } + + #[test] + fn padding_bits_and_trailing_bytes_are_ignored() { + // 3 columns: the last 5 bits of each byte pad the row; set them to anything. + let data = [0b1010_1111, 0b0101_0000, 0b1110_1010, 0xAB, 0xCD]; + let g4 = encode_g4(&data, 3, 3).unwrap(); + assert_eq!(decode(&g4, 3, 3), [[true, false, true], [false, true, false], [true, true, true]]); + } + + #[test] + fn what_it_cannot_encode_is_an_error() { + assert!(matches!(encode_g4(&[0; 8], 0, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&[0; 8], 8, 0), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&vec![0; 4097], MAX_G4_COLUMNS + 1, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&[0; 3], 16, 2), Err(FilterError::Corrupt { .. })), "two rows of 16 need 4 bytes"); + assert!(matches!(encode_g4(&[], 1, u32::MAX), Err(FilterError::Corrupt { .. }))); + let widest = vec![0x55; (MAX_G4_COLUMNS as usize).div_ceil(8) * 2]; + let g4 = encode_g4(&widest, MAX_G4_COLUMNS, 2).unwrap(); + assert_eq!(decode(&g4, MAX_G4_COLUMNS, 2), unpack(&widest, MAX_G4_COLUMNS, 2)); + } + + /// Rows of `columns` samples built from `runs`: alternating white and black runs one longer + /// than those values (so never empty, even when shrinking makes them all 0), repeated until the + /// rows are full; or, with `noise`, the bytes of `runs` as they are. + fn bitmap(columns: u32, rows: u32, runs: &[u8], noise: bool) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut data = vec![0u8; stride * rows as usize]; + if runs.is_empty() { + return data; + } + if noise { + data.iter_mut().zip(runs.iter().cycle()).for_each(|(d, r)| *d = *r); + return data; + } + let (mut white, mut left, mut next) = (true, 1 + usize::from(runs[0]), 1); + for y in 0..rows as usize { + for x in 0..columns as usize { + if left == 0 { + white = !white; + left = 1 + usize::from(runs[next % runs.len()]); + next += 1; + } + if white { + data[y * stride + x / 8] |= 0x80 >> (x % 8); + } + left -= 1; + } + } + data + } + + fn g4(columns: u32) -> CcittParams { + CcittParams { k: -1, columns: i64::from(columns), ..CcittParams::default() } + } + + #[test] + fn decoding_gives_the_rows_back() { + // 3 columns, padded with anything: the decoded padding is 0. + let data = [0b1010_1111, 0b0101_0000, 0b1110_1010]; + let coded = encode_g4(&data, 3, 3).unwrap(); + assert_eq!(decode_ccitt(&coded, &g4(3), 3).unwrap(), [0b1010_0000, 0b0100_0000, 0b1110_0000]); + // /BlackIs1 gives black as 1. + let p = CcittParams { black_is_1: true, ..g4(3) }; + assert_eq!(decode_ccitt(&coded, &p, 3).unwrap(), [0b0100_0000, 0b1010_0000, 0b0000_0000]); + // At most /Rows rows, and at most max_rows. + assert_eq!(decode_ccitt(&coded, &CcittParams { rows: 2, ..g4(3) }, 3).unwrap().len(), 2); + assert_eq!(decode_ccitt(&coded, &g4(3), 1).unwrap().len(), 1); + assert_eq!(decode_ccitt(&coded, &CcittParams { rows: 3, ..g4(3) }, 1).unwrap().len(), 1, "max_rows bounds /Rows"); + assert_eq!(decode_ccitt(&coded, &g4(3), 0).unwrap(), [0u8; 0]); + // Damaged data gives the rows before the damage; none here. + assert!(decode_ccitt(&coded[..1], &g4(3), 3).unwrap().len() < 3); + } + + #[test] + fn what_it_cannot_decode_is_an_error() { + for columns in [0, -1, i64::from(MAX_CCITT_COLUMNS) + 1, i64::MAX] { + let p = CcittParams { columns, ..CcittParams::default() }; + assert!(matches!(decode_ccitt(&[0; 8], &p, 4), Err(FilterError::Unsupported(_))), "{columns}"); + } + assert_eq!(CcittParams::default().columns, 1728, "Table 11"); + } + + /// Group 3 data: rows of run lengths below 64, white first, coded one-dimensionally with T.4's + /// terminating codes (ITU-T T.4 Tables 2 and 3). Each row starts with an end-of-line code with + /// `eol`, then with `tag` a 1 bit (the next row one-dimensional, as `k` above 0 has it). Fill + /// bits (0s) go where `fill` says. + fn group_3(rows: &[Vec], eol: bool, tag: bool, fill: Fill) -> Vec { + // The codes for runs 0 to 63, in order. + const WHITE: &str = concat!( + "00110101 000111 0111 1000 1011 1100 1110 1111 10011 10100 00111 01000 001000 000011 110100 110101 101010 101011 ", + "0100111 0001100 0001000 0010111 0000011 0000100 0101000 0101011 0010011 0100100 0011000 00000010 00000011 00011010 ", + "00011011 00010010 00010011 00010100 00010101 00010110 00010111 00101000 00101001 00101010 00101011 00101100 00101101 ", + "00000100 00000101 00001010 00001011 01010010 01010011 01010100 01010101 00100100 00100101 01011000 01011001 01011010 ", + "01011011 01001010 01001011 00110010 00110011 00110100", + ); + const BLACK: &str = concat!( + "0000110111 010 11 10 011 0011 0010 00011 000101 000100 0000100 0000101 0000111 00000100 00000111 000011000 0000010111 ", + "0000011000 0000001000 00001100111 00001101000 00001101100 00000110111 00000101000 00000010111 00000011000 ", + "000011001010 000011001011 000011001100 000011001101 000001101000 000001101001 000001101010 000001101011 000011010010 ", + "000011010011 000011010100 000011010101 000011010110 000011010111 000001101100 000001101101 000011011010 000011011011 ", + "000001010100 000001010101 000001010110 000001010111 000001100100 000001100101 000001010010 000001010011 000000100100 ", + "000000110111 000000111000 000000100111 000000101000 000001011000 000001011001 000000101011 000000101100 000001011010 ", + "000001100110 000001100111", + ); + let code = |white: bool, run: u32| (if white { WHITE } else { BLACK }).split(' ').nth(run as usize).expect("a run below 64"); + let mut bits = String::new(); + for row in rows { + // The end-of-line code is 12 bits. + let ends_code = if fill == Fill::CodeEnd && eol { 12 } else { 0 }; + while fill != Fill::None && !(bits.len() + ends_code).is_multiple_of(8) { + bits.push('0'); + } + if eol { + bits.push_str("000000000001"); + } + if tag { + bits.push('1'); + } + row.iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run))); + } + bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect() + } + + /// Where [`group_3`] puts fill bits: none; so each row, its end-of-line code first, starts on a + /// byte; or so each end-of-line code ends on one (T.4's fill, as TIFF writes it). + #[derive(Clone, Copy, Debug, PartialEq)] + enum Fill { + None, + RowStart, + CodeEnd, + } + + /// `rows` text-like rows of `columns` samples as runs, white first, from `seed`: long white runs + /// and short black ones; a row may start black. + fn text_like(seed: u64, columns: u32, rows: u32) -> Vec> { + let mut seed = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1; + let mut random = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n) as u32 + }; + (0..rows) + .map(|_| { + let (mut runs, mut left) = (Vec::new(), columns); + while left > 0 { + let run = match runs.len() % 2 { + 0 if runs.is_empty() => random(31), + 0 => 1 + random(30), + _ => 1 + random(5), + }; + runs.push(run.min(left)); + left -= run.min(left); + } + runs + }) + .collect() + } + + /// Rows of runs, white first, packed as `decode_ccitt` gives them (white 1). + fn pack(runs: &[Vec], columns: u32) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut out = vec![0u8; stride * runs.len()]; + for (y, row) in runs.iter().enumerate() { + let mut x = 0; + for (i, run) in row.iter().enumerate() { + if i % 2 == 0 { + (x..x + *run as usize).for_each(|x| out[y * stride + x / 8] |= 0x80 >> (x % 8)); + } + x += *run as usize; + } + } + out + } + + /// Group 3 rows on bytes in each layout, over many pictures, as how a misreading comes out + /// depends on the picture: no codes (as TIFF's Modified Huffman has it), and `end_of_line` + /// without them; codes, each starting on a byte or ending on one, `end_of_line` or not. Each + /// decodes to its picture, and a byte short to fewer rows, not to other rows read another way. + /// Group 4 rows on bytes keep their aligned read, `end_of_line` or not. + #[test] + fn group_3_rows_on_bytes_read_by_the_codes_in_the_data() { + for seed in 0..32u64 { + let (columns, rows) = (8 + (seed as u32 * 37) % 200, 2 + seed as u32 % 20); + let runs = text_like(seed, columns, rows); + let picture = pack(&runs, columns); + for k in [0, 1] { + for (codes, fill, end_of_line) in [ + (false, Fill::RowStart, false), + (false, Fill::RowStart, true), + (true, Fill::RowStart, false), + (true, Fill::RowStart, true), + (true, Fill::CodeEnd, false), + (true, Fill::CodeEnd, true), + ] { + let p = CcittParams { k, columns: i64::from(columns), end_of_line, encoded_byte_align: true, ..CcittParams::default() }; + let mut data = group_3(&runs, codes, k > 0, fill); + let case = format!("seed {seed}, {columns} × {rows}, k {k}, codes {codes}, fill {fill:?}, end_of_line {end_of_line}"); + assert_eq!(decode_ccitt(&data, &p, rows).unwrap(), picture, "{case}"); + data.pop(); + assert!(decode_ccitt(&data, &p, rows).unwrap().len() < picture.len(), "{case}, a byte short"); + } + } + } + // White rows, each the one code 1 (vertical, no change) and its fill. + for end_of_line in [false, true] { + let p = CcittParams { k: -1, columns: 64, end_of_line, encoded_byte_align: true, ..CcittParams::default() }; + assert_eq!(decode_ccitt(&[0x80; 16], &p, 16).unwrap(), [0xFF; 8 * 16], "Group 4, end_of_line {end_of_line}"); + } + } + + proptest::proptest! { + /// Any bitmap, of noise or of runs, decodes to its own bits, through an independent decoder + /// and through `decode_ccitt`. + #[test] + fn bitmaps_round_trip( + columns in 1u32..400, + rows in 1u32..24, + runs in proptest::collection::vec(0u8..=255, 1..48), + noise in proptest::bool::ANY, + ) { + let data = bitmap(columns, rows, &runs, noise); + let coded = encode_g4(&data, columns, rows).unwrap(); + proptest::prop_assert_eq!(decode(&coded, columns, rows), unpack(&data, columns, rows)); + let decoded = decode_ccitt(&coded, &g4(columns), rows).unwrap(); + proptest::prop_assert_eq!(unpack(&decoded, columns, rows), unpack(&data, columns, rows)); + } + + /// Whatever it is given, it returns. + #[test] + fn any_input_returns( + data in proptest::collection::vec(0u8..=255, 0..600), + columns in 0u32..40_000, + rows in 0u32..80, + k in -2i64..3, + end_of_line in proptest::bool::ANY, + encoded_byte_align in proptest::bool::ANY, + ) { + let _ = encode_g4(&data, columns, rows); + let p = CcittParams { k, columns: i64::from(columns), end_of_line, encoded_byte_align, ..CcittParams::default() }; + if let Ok(out) = decode_ccitt(&data, &p, rows) { + proptest::prop_assert!(out.len() <= (columns as usize).div_ceil(8) * rows as usize); + } + } + } +} diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 22a78aefdb..63daefc4ea 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -6,7 +6,8 @@ //! TIFF/PNG predictors, `ASCIIHexDecode`, `ASCII85Decode`, `RunLengthDecode`). //! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`), //! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so -//! callers keep such data encoded. +//! callers keep such data encoded. 1-bit samples can be encoded as CCITT Group 4 +//! ([`encode_g4`]). //! //! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb //! defence) and never panics on malformed input. @@ -15,6 +16,7 @@ mod ascii85; mod asciihex; +mod ccitt; mod flate; mod lzw; mod predictor; @@ -22,6 +24,8 @@ mod runlength; use std::borrow::Cow; +pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, encode_g4}; + /// A PDF stream filter, identified by its `/Filter` name. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Filter { diff --git a/crates/optimize/Cargo.toml b/crates/optimize/Cargo.toml index 456ab4c991..d1a759d468 100644 --- a/crates/optimize/Cargo.toml +++ b/crates/optimize/Cargo.toml @@ -24,3 +24,4 @@ zune-jpeg = "0.5" [dev-dependencies] pdfcraft-render = { workspace = true } +proptest.workspace = true diff --git a/crates/optimize/README.md b/crates/optimize/README.md index cbe37f6b7b..dec4178672 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -11,7 +11,9 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going ``` - **Presets:** Standard (Reduce File Size's choices: images above 225 ppi to 150 ppi, JPEG - medium) and Mobile (above 144 ppi to 96 ppi, JPEG high), as in Acrobat's Settings menu. + medium) and Mobile (above 144 ppi to 96 ppi, JPEG high), as in Acrobat's Settings menu. In + both, black-and-white images are averaged down to 300 ppi above 450 ppi, and stored as CCITT + Group 4 whenever that is smaller. - **Merge image fragments** (`fragments`, on by default): some PDF printers draw a page picture as thousands of images one pixel row high. Runs of upright 8-bit grey or RGB images in one colour space, sharing their left and right edges and stacked edge to edge (at least four, @@ -27,11 +29,60 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. - An image is replaced only if the result is smaller. Left alone: CMYK and other colour spaces, - image masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT. A JPEG is read with + An image is replaced only if the result is smaller. Left alone by these colour and grey + settings: CMYK and other colour spaces, image masks, decode arrays, more than 8 bits, JPEG 2000, + JBIG2 and CCITT (black-and-white images, CCITT among them, are below). A JPEG is read with its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one stored untransformed keeps its colours; one whose marker and `/ColorTransform` disagree is left - alone, since readers differ on which wins. + alone, since readers differ on which wins. Samples are read with `/DecodeParms` given by + reference too, the numbers inside included (`Document::with_direct_parms`), here and below: + without them a predictor's tag bytes would shift every row of an image written back. +- **Monochrome images** (`Settings::mono`, `MonoSettings::REDUCE` by default: to 300 ppi above + 450 ppi, CCITT Group 4). Black-and-white images are a 1-bit image with one colour component + (grey, indexed, a separation) or an image mask, stored with no filter, with general-purpose ones + or with CCITT fax after any of those (decoded by `Document::decoded_image_within`, no further + than its rows can need; byte-aligned Group 3 with end-of-line codes but none before the first + row, not as T.4 sends them, is read as PdfCraft draws it, which at `/K 0` may be other rows than + were coded, and an image stored again keeps them). Packed to one bit first, unless a colour-key `/Mask` is in the image's + own values: eight-bit grey of only 0 and 255; eight-bit `DeviceRGB` of only black and white, + which becomes `DeviceGray` (not calibrated RGB or RGB with a profile, nor one drawn or listed + where `/DefaultGray` or `/DefaultRGB` is in force, which would then draw it otherwise, nor one + referred to from anywhere but the page and form resources the optimizer reads, where they may + be: an annotation's appearance or a pattern drawing it, directly or through resources or a form + it shares with a page, or another image's `/Alternates`; nor one whose soft mask has a + `/Matte`); and an indexed image of 2, 4 or 8 bits that uses only palette entries + 0 and 1, which keeps its palette, whatever its two colours. A decode array stays when it means + the same at one bit (grey's, or RGB's if the same for each component, as grey's), and an indexed + image's default one goes, as the default at one bit; otherwise the image is left alone. The grey + or colour settings take a packed image instead when they make it smaller still, its soft mask + counted (resampled with it there, kept as it is here), as they can a dithered scan by + downsampling it to JPEG. + - **Downsampling:** one whose effective resolution is above `above_ppi` is averaged down to + `target_ppi`: each new pixel takes the sample covering more of its area, and the dark one on a + tie, so a dark line half a new pixel wide stays: black in grey and the painted part of an image + mask (a 0, or a 1 with an inverting decode array), full ink in a separation (a 1, or a 0), and + entry 0 of an indexed image, whatever colour its palette gives it (the palette isn't read yet). + Not one with a soft mask, whose own size would have to follow; not a halftone or a dither, + most of whose new pixels would cover both black and white, so that taking the sample covering + more would turn its tones solid (a 50% screen black, a 25% one white); and not one wider than + a fax row (`pdfcraft_filters::MAX_CCITT_COLUMNS`), as the averaging keeps a counter for each + column. On a 600 ppi text page, Group 4 at 300 ppi is half the size. + - **Compression:** CCITT Group 4 (`pdfcraft_filters::encode_g4`), or 1-bit Flate when that is + smaller, which only a resampled image or one packed from more bits may be: a 1-bit image that + is only stored again becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; + Group 3, or Group 4 inside another filter such as `ASCII85Decode`, is stored again as Group 4 + alone when that is smaller. Damaged Group 3 with end-of-line codes is stored as the decoder + recovers it, a bad row picked up again at the next code, as it reports no damage there; without + them, damage stops the decoding and the image is left alone. With Retain, a resampled image + keeps its kind of compression (CCITT, or Flate) and nothing else changes; an image of more bits + is then left to the grey or colour settings. Every change is kept only when it is smaller as + stored. + - Apart from the downsampling it is lossless: the samples keep their meaning, so the colour + space, decode array and `/Mask` stay, but for packed RGB, which becomes grey with one decode + pair. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit + art Flate can be smaller. Not yet: JBIG2, inline images, an image's own `/SMask` or `/Mask` + image (only images drawn on a page are visited), and Group 4 for rows wider than 32,767 + samples. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index b9761b85fc..54e382d133 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -5,18 +5,25 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::{Matrix, Pieces}; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; -use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; +use crate::{Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Report, Settings, Stage}; /// Images with more pixels than this are left alone (memory). const MAX_PIXELS: u64 = 80_000_000; /// The resources of a page, inherited from the page tree if the page has none. pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { + held_page_resources(doc, page).map(|(resources, _)| resources) +} + +/// [`page_resources`], and the object holding them: the page or the page-tree node they are +/// written in (inherited from), or the resources dictionary when it is an object of its own. +fn held_page_resources(doc: &Document, page: ObjRef) -> Option<(Dict, ObjRef)> { let mut node = Some(page); for _ in 0..64 { - let d = doc.get(node?).as_dict()?.clone(); + let n = node?; + let d = doc.get(n).as_dict()?.clone(); if let Some(r) = d.get(b"Resources") { - return doc.resolve(r).as_dict().cloned(); + return Some((doc.resolve(r).as_dict().cloned()?, r.as_ref().unwrap_or(n))); } node = d.reference(b"Parent"); } @@ -41,9 +48,10 @@ struct Walk<'a> { doc: &'a Document, /// The forms being walked right now, outermost first: a form drawing one of these is a cycle. path: Vec, - /// Forms with their own resources already walked, by the linear part of their matrix. Drawing - /// one again at the same scale (only moved) can't change any image's resolution. - walked: HashSet<(ObjRef, [u64; 4])>, + /// Forms with their own resources already walked, by the linear part of their matrix and + /// whether default colour spaces were in force. Drawing one again at the same scale (only + /// moved) can't change any image's resolution. + walked: HashSet<(ObjRef, [u64; 4], bool)>, /// Forms walked at least once. seen: HashSet, rewalks: usize, @@ -52,14 +60,56 @@ struct Walk<'a> { /// False once a use may have been missed (depth or work limit). complete: bool, out: HashMap, + /// Images drawn or listed at least once where `/DefaultGray` or `/DefaultRGB` is in force, by + /// object number: a reference with another generation reaches the same image. + defaults: HashSet, + /// The references the walk followed to reach its images, and the pages and page-tree nodes + /// whose resources it read. + read: Read, +} + +/// Where a walk read a reference: under a name in an `/XObject` dictionary (an image or a form), +/// as resources' `/XObject` dictionary, or as the `/Resources` of a page, node or form. +#[derive(Clone, PartialEq, Eq, Hash)] +enum Entry { + Name(Vec), + XObject, + Resources, +} + +/// The references a walk followed, by object number: (the object referred to, the object holding +/// the reference, where in it), and the pages and page-tree nodes whose resources it read. +#[derive(Default)] +struct Read { + entries: HashSet<(u32, u32, Entry)>, + roots: HashSet, } impl Walk<'_> { /// Walk one content stream, recording each image's displayed resolution (pixels per inch, the - /// smaller of the two axes) and descending into form XObjects. - fn content(&mut self, data: &[u8], resources: &Dict, ctm: Matrix) { + /// smaller of the two axes) and descending into form XObjects. `holder` is the object holding + /// `resources`. `defaults`: an enclosing stream's resources remap a device colour space. + fn content(&mut self, data: &[u8], resources: &Dict, holder: ObjRef, ctm: Matrix, defaults: bool) { let doc = self.doc; + let defaults = defaults || remaps_device_spaces(doc, resources); let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let xholder = match resources.get(b"XObject").and_then(Object::as_ref) { + Some(x) => { + self.read.entries.insert((x.num, holder.num, Entry::XObject)); + x + } + None => holder, + }; + // Every entry counts as read, drawn here or not: content using these resources (a Type3 + // glyph without its own, say) draws it under the same defaults. + for (name, v) in xobjects.iter() { + if let Some(r) = v.as_ref() { + self.read.entries.insert((r.num, xholder.num, Entry::Name(name.to_vec()))); + if defaults { + self.defaults.insert(r.num); + } + } + } let mut stack = vec![ctm]; for op in pdfcraft_content::parse(data).ops { let top = stack.last().copied().unwrap_or(ctm); @@ -90,8 +140,11 @@ impl Walk<'_> { let e = self.out.entry(r).or_insert(f64::INFINITY); *e = e.min(ppi); } + if defaults { + self.defaults.insert(r.num); + } } - Some(b"Form") => self.form(r, s, resources, top), + Some(b"Form") => self.form(r, s, (resources, holder), top, defaults), _ => {} } } @@ -100,8 +153,37 @@ impl Walk<'_> { } } - /// Descend into form `r` drawn with `ctm` from a stream whose resources are `resources`. - fn form(&mut self, r: ObjRef, s: &Stream, resources: &Dict, ctm: Matrix) { + /// Read the resources of page `p` and of each page-tree node above it, each of them a root: a + /// node's resources are its inheriting pages' (every page is walked), or no page's. + fn resources_above(&mut self, p: ObjRef) { + let doc = self.doc; + let mut node = Some(p); + for _ in 0..64 { + let Some(n) = node else { return }; + // A node met before had its own nodes above it read then. + if !self.read.roots.insert(n.num) { + return; + } + let Some(d) = doc.get(n).as_dict().cloned() else { return }; + if let Some(r) = d.get(b"Resources") + && let Some(resources) = doc.resolve(r).as_dict().cloned() + { + let holder = match r.as_ref() { + Some(res) => { + self.read.entries.insert((res.num, n.num, Entry::Resources)); + res + } + None => n, + }; + self.content(&[], &resources, holder, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false); + } + node = d.reference(b"Parent"); + } + } + + /// Descend into form `r` drawn with `ctm` from a stream whose resources are `resources`, with + /// the object holding them. + fn form(&mut self, r: ObjRef, s: &Stream, (resources, holder): (&Dict, ObjRef), ctm: Matrix, defaults: bool) { if self.path.contains(&r) { return; } @@ -116,7 +198,7 @@ impl Walk<'_> { // A form without resources uses its caller's, so the same form can draw other images. if own.is_some() { let [a, b, c, d, ..] = inner.0; - if !self.walked.insert((r, [a.to_bits(), b.to_bits(), c.to_bits(), d.to_bits()])) { + if !self.walked.insert((r, [a.to_bits(), b.to_bits(), c.to_bits(), d.to_bits()], defaults)) { return; } } @@ -136,17 +218,43 @@ impl Walk<'_> { return; }; self.path.push(r); - self.content(&data, own.as_ref().unwrap_or(resources), inner); + match &own { + Some(own) => { + let own_holder = match s.dict.get(b"Resources").and_then(Object::as_ref) { + Some(res) => { + self.read.entries.insert((res.num, r.num, Entry::Resources)); + res + } + None => r, + }; + self.content(&data, own, own_holder, inner, defaults); + } + None => self.content(&data, resources, holder, inner, defaults), + } self.path.pop(); } } +/// Whether `resources` remap a device colour space an image may be moved between, so that the +/// same samples would be drawn otherwise: `/DefaultGray` or `/DefaultRGB` (ISO 32000-2 §8.6.5.6). +fn remaps_device_spaces(doc: &Document, resources: &Dict) -> bool { + let spaces = resources.get(b"ColorSpace").map(|c| doc.resolve(c)); + spaces.as_deref().and_then(Object::as_dict).is_some_and(|c| c.contains(b"DefaultGray") || c.contains(b"DefaultRGB")) +} + /// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses. /// /// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within /// its budget, every image is given `INFINITY` (never resampled), since its largest use may be /// among those missed. pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap { + walk_pages(doc, pages).0 +} + +/// [`effective_resolutions`], the images drawn or listed where `/DefaultGray` or `/DefaultRGB` is +/// in force, by object number (all of them when a use may have been missed), and the references +/// followed to reach them. +fn walk_pages(doc: &Document, pages: &[ObjRef]) -> (HashMap, HashSet, Read) { let mut walk = Walk { doc, path: Vec::new(), @@ -156,12 +264,15 @@ pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap HashMap bool { + let Object::Stream(s) = &*doc.get(r) else { return false }; + let cs = s.dict.get(b"ColorSpace").map(|cs| doc.resolve(cs)); + let codec = + s.filters().iter().any(|(f, _)| matches!(f, pdfcraft_filters::Filter::Dct | pdfcraft_filters::Filter::Jpx | pdfcraft_filters::Filter::Jbig2)); + s.dict.int(b"BitsPerComponent") == Some(8) + && !codec + && cs.as_deref().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceRGB" | b"RGB")) +} + +/// Call `f` with every reference in `o`, a dictionary's or array's included, a stream's dictionary +/// too, but not those in objects referred to. +fn each_ref(o: &Object, mut f: impl FnMut(ObjRef)) { + // Direct objects nest finitely (the parser builds trees), so this ends. + let mut stack = vec![o]; + while let Some(o) = stack.pop() { + match o { + Object::Ref(r) => f(*r), + Object::Dict(d) => stack.extend(d.iter().map(|(_, v)| v)), + Object::Array(a) => stack.extend(a), + Object::Stream(s) => stack.extend(s.dict.iter().map(|(_, v)| v)), + _ => {} + } + } +} + +/// The images among `images` (by object number) that may be drawn beyond what the walk read: +/// some object the document uses refers to the image, or to an object on the way to it from a +/// page (an `/XObject` dictionary, resources, a form), other than as the page and form resources +/// the walk read do. So an annotation's appearance, a pattern, a Type3 glyph or a soft mask's +/// group drawing it, directly or through resources or a form it shares with a page, counts, as +/// other default colour spaces may be in force there, and so does any other reference (another +/// image's `/Alternates`, say). Objects nothing reaches from the trailer don't count. +fn used_elsewhere(doc: &Document, images: &HashSet, read: &Read) -> HashSet { + if images.is_empty() { + return HashSet::new(); + } + let targets: HashSet = read.entries.iter().map(|(target, ..)| *target).collect(); + // References to each object the walk followed, by the object holding them. + let mut held: HashMap> = HashMap::new(); + let mut queue: Vec = Vec::new(); + let mut visited = HashSet::new(); + for (_, v) in doc.trailer().iter() { + each_ref(v, |r| queue.push(r.num)); + } + while let Some(num) = queue.pop() { + if !visited.insert(num) { + continue; + } + let obj = doc.get(ObjRef::new(num, doc.generation(num))); + each_ref(&obj, |r| { + if targets.contains(&r.num) { + *held.entry(r.num).or_default().entry(num).or_default() += 1; + } + if !visited.contains(&r.num) { + queue.push(r.num); + } + }); + } + let mut seen: HashMap<(u32, u32), usize> = HashMap::new(); + for (target, holder, _) in &read.entries { + *seen.entry((*target, *holder)).or_default() += 1; + } + // Covered: every reference to it is one the walk followed, held by a page or node, or by an + // object covered in turn. Everything the walk reached starts covered, and whatever has a + // reference it didn't follow, or is held by an object taken out, is taken out, until nothing + // more is. Content beyond what the walk read has to refer to an object on its way to the + // image, which takes out everything after it; a cycle the walk read whole stays covered. + let mut holds: HashMap> = HashMap::new(); + for (target, holders) in &held { + for h in holders.keys() { + holds.entry(*h).or_default().push(*target); + } + } + let mut covered: HashSet = held.keys().copied().collect(); + let passes = |t: u32, covered: &HashSet| { + held.get(&t) + .is_some_and(|holders| holders.iter().all(|(h, n)| seen.get(&(t, *h)) == Some(n) && (read.roots.contains(h) || covered.contains(h)))) + }; + let mut check: Vec = covered.iter().copied().collect(); + while let Some(t) = check.pop() { + if covered.contains(&t) && !passes(t, &covered) { + covered.remove(&t); + check.extend(holds.get(&t).into_iter().flatten().copied()); + } + } + images.iter().copied().filter(|i| !covered.contains(i)).collect() } /// Colour components of a colour space PdfCraft resamples: gray (1) or RGB (3). @@ -258,8 +460,11 @@ fn count_decoded(_n: u64) { DECODED_PIXELS.with(|c| c.set(c.get().saturating_add(_n))); } -/// 8-bit samples of an image (`n` components), decoded. `None` for a JPEG whose colour transform -/// readers may disagree about ([`ycc_encoded`]). +/// 8-bit samples of an image (`n` components), decoded, with `/DecodeParms` given by reference +/// too ([`Document::with_direct_parms`]): read without them, a predictor's tag bytes would shift +/// every row, and the image would be written back wrong. `None` for a JPEG whose colour transform +/// readers may disagree about ([`ycc_encoded`]), and for data that decodes past what the samples +/// can need ([`allowance`]). pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option> { let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { @@ -275,11 +480,19 @@ pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option> { count_decoded(u64::from(w) * u64::from(h)); (v.len() == bytes).then_some(v) } else { - let v = s.decoded().ok()?; + let v = doc.with_direct_parms(s).decoded_within(allowance(bytes, h)?).ok()?; Some(v.get(..bytes)?.to_vec()) } } +/// How far an image of `bytes` samples in `rows` rows is decoded: a filter stage may produce up +/// to three bytes for each of them and of a PNG predictor's bytes a row (ASCIIHex text under +/// Flate, its two digits a byte broken into lines or spaced). More can only be a decompression +/// bomb, and the image is left alone. +fn allowance(bytes: usize, rows: u32) -> Option { + bytes.checked_add(usize::try_from(rows).ok()?)?.checked_mul(3)?.checked_add(4096) +} + /// Whether the samples of a three-component JPEG are YCbCr, to be converted to RGB (ISO 32000-2 /// §7.4.8). An Adobe APP14 marker's transform flag says so, overriding the filter's /// /ColorTransform; without a marker /ColorTransform does, 1 (YCbCr) by default. @@ -532,6 +745,329 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us (after < before).then_some((new, smask)) } +/// Whether colour space `cs` has one colour component, so a 1-bit image in it has one bit per +/// pixel: grey, a separation, an indexed space or a DeviceN of one colourant. Of names, only +/// DeviceGray counts, with `/G`, its abbreviation, which readers take as grey too: another name +/// is no colour space on its own (`/Indexed` and `/CalGray` need their parameters), so it can only +/// name a resource, which might be RGB. +fn one_component(doc: &Document, cs: &Object) -> bool { + let cs = doc.resolve(cs); + match &*cs { + Object::Name(n) => matches!(n.as_slice(), b"DeviceGray" | b"G"), + Object::Array(a) => match a.first().and_then(Object::as_name) { + Some(b"CalGray" | b"Separation" | b"Indexed" | b"I") => true, + Some(b"ICCBased") => a.get(1).is_some_and(|icc| matches!(&*doc.resolve(icc), Object::Stream(s) if s.dict.int(b"N") == Some(1))), + Some(b"DeviceN") => a.get(1).is_some_and(|names| doc.resolve(names).as_array().is_some_and(|n| n.len() == 1)), + _ => false, + }, + _ => false, + } +} + +/// A pixel's samples as a 1 or a 0, or `None` for a colour that is neither. +type Pixel = fn(&[u8]) -> Option; + +/// The samples of a black-and-white image, as rows of 1-bit samples each starting on a new byte, +/// with its width and height, and, when they were packed from more bits, its dictionary as a 1-bit +/// image. These count: +/// - one bit per pixel in one colour component, or an image mask, stored with no filter, with +/// general-purpose ones, or with CCITT fax last ([`Document::decoded_image_within`], no further +/// than its rows can need); +/// - with `pack`, and with no colour-key `/Mask` (its ranges are in the image's own values): +/// - eight-bit grey (one component, not indexed) whose every sample is 0 or 255. A decode array +/// maps 0 and 255 at eight bits as it maps 0 and 1 at one bit, so it stays; +/// - eight-bit `DeviceRGB` whose every pixel is black or white, which becomes `DeviceGray`. A +/// decode array the same for all three components becomes that one pair; another stops it. +/// Calibrated RGB and RGB with a profile are left out, as the profile could make them other +/// than black and white; so is one drawn where `/DefaultGray` or `/DefaultRGB` is or may be in +/// force (`defaults`), which would draw it otherwise as grey, and one whose soft mask has a +/// `/Matte` of three components; +/// - an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1, which stay 0 and +/// 1 at one bit, whatever their colours, so the colour space stays. A decode array other than +/// the default stops it: in index units it would mean something else at one bit. +fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Vec, u32, u32, Option)> { + let d = &s.dict; + // Through u32 first, as everywhere here, so the product can't overflow. + let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; + } + let (wu, hu) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + let stride = wu.div_ceil(8); + let bits = d.int(b"BitsPerComponent"); + let mask = matches!(d.get(b"ImageMask"), Some(Object::Bool(true))); + if (mask && bits.is_none_or(|b| b == 1)) || (bits == Some(1) && d.get(b"ColorSpace").is_some_and(|cs| one_component(doc, cs))) { + // No further than its rows can need: a fax image's code (the filters before its decoder + // produce it) takes at most about a byte a pixel and each row's end, as redaction allows. + let len = stride.checked_mul(hu)?; + let max = match s.filters().last() { + Some((pdfcraft_filters::Filter::CcittFax, _)) => wu.checked_mul(hu)?.checked_add(hu.checked_mul(4)?)?.checked_add(4096)?, + _ => allowance(len, h)?, + }; + let mut data = doc.decoded_image_within(s, max).ok()?; + if data.len() < len { + return None; + } + data.truncate(len); + return Some((data, w, h, None)); + } + let colour_key = d.get(b"Mask").is_some_and(|m| doc.resolve(m).as_array().is_some()); + if !pack || mask || colour_key { + return None; + } + let cs = d.get(b"ColorSpace")?; + // The decode array's numbers, each given directly or by reference, as integers or reals; `None` + // when it has something else. + let decode = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() { + None => None, + Some(Object::Array(v)) => Some(v.iter().map(|e| doc.resolve(e).as_f64()).collect::>>()?), + Some(_) => return None, + }; + let mut packed = d.clone(); + packed.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + // Components a pixel, bits a component, and a pixel's samples as 1 or 0. + let (n, bpc, white): (usize, usize, Pixel) = match &*doc.resolve(cs) { + Object::Array(a) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { + let bpc = bits.filter(|b| matches!(b, 2 | 4 | 8))?; + match decode.as_deref() { + None => {} + Some([d0, d1]) if *d0 == 0.0 && *d1 == f64::from((1u32 << bpc) - 1) => { + packed.remove(b"Decode"); + } + Some(_) => return None, + } + (1, usize::try_from(bpc).ok()?, |v| match v { + [0] => Some(false), + [1] => Some(true), + _ => None, + }) + } + Object::Name(name) if matches!(name.as_slice(), b"DeviceRGB" | b"RGB") && bits == Some(8) => { + let matte = |m: &Object| matches!(&*doc.resolve(m), Object::Stream(m) if m.dict.contains(b"Matte")); + if defaults || d.get(b"SMask").is_some_and(matte) { + return None; + } + match decode.as_deref() { + None => {} + Some(v @ [d0, d1, ..]) if v.len() == 6 && v.chunks(2).all(|pair| pair == [*d0, *d1]) => { + let number = |n: f64| if n.fract() == 0.0 { Object::Int(n as i64) } else { Object::Real(n) }; + packed.set(b"Decode".to_vec(), Object::Array(vec![number(*d0), number(*d1)])); + } + Some(_) => return None, + } + packed.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + (3, 8, |v| match v { + [0, 0, 0] => Some(false), + [255, 255, 255] => Some(true), + _ => None, + }) + } + _ if bits == Some(8) && one_component(doc, cs) => (1, 8, |v| match v { + [0] => Some(false), + [255] => Some(true), + _ => None, + }), + _ => return None, + }; + let row_len = wu.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8); + // Through its parameters given by reference too: without a PNG predictor's tag bytes taken + // off, rows of 0 and 255 (or palette entries 0 and 1) would still pass below, each shifted. + let samples = doc.with_direct_parms(s).decoded_within(allowance(row_len.checked_mul(hu)?, h)?).ok()?; + Some((packed_rows(&samples, row_len, wu, hu, (n, bpc, white))?, w, h, Some(packed))) +} + +/// `h` rows of `w` pixels, each row `row_len` bytes of `n` samples a pixel at `bpc` bits (8, or +/// a divisor of 8 with `n` 1), as rows of 1-bit samples each starting on a new byte: 1 where +/// `white` says the pixel is white. `None` when a pixel is neither, `samples` is short, or the +/// rows have another shape: no pixels in a row, other sample sizes, or too few bytes for their +/// pixels. +fn packed_rows(samples: &[u8], row_len: usize, w: usize, h: usize, (n, bpc, white): (usize, usize, Pixel)) -> Option> { + let stride = w.div_ceil(8); + let shape = (n > 0 && bpc == 8) || (n == 1 && matches!(bpc, 1 | 2 | 4)); + if stride == 0 || !shape || row_len < w.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8) { + return None; + } + // From here no row is empty and x·n·bpc stays within one, and the output is no larger than + // the samples it is read from. + let samples = samples.get(..row_len.checked_mul(h)?)?; + let mut out = vec![0u8; stride.checked_mul(h)?]; + for (row, out_row) in samples.chunks(row_len).zip(out.chunks_mut(stride)) { + for x in 0..w { + let on = if bpc == 8 { + white(row.get(x * n..x * n + n)?)? + } else { + // Samples narrower than a byte, the first in its high bits; bpc divides 8. + let at = x * bpc; + white(&[(row.get(at / 8)? >> (8 - bpc - at % 8)) & ((1u8 << bpc) - 1)])? + }; + if on { + *out_row.get_mut(x / 8)? |= 0x80 >> (x % 8); + } + } + } + Some(out) +} + +/// Whether sample 1 is the dark one of the 1-bit image `d` describes, after its decode array +/// (`[0 1]` when it has none of two numbers): the painted part of an image mask and black in grey +/// decode to 0, and full ink in a separation (or a DeviceN of one colourant) to 1. An indexed image +/// says the sample that decodes to entry 0 (its decode array maps samples to entries, ISO 32000-2 +/// §8.9.5.2): its palette says which entry is darker, and the palette isn't read yet, so a tie goes +/// to entry 0 whatever its colour. +fn dark_is_one(doc: &Document, d: &Dict) -> bool { + let number = |v: Option<&Object>| v.and_then(|v| doc.resolve(v).as_f64()); + let (d0, d1) = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() { + Some(Object::Array(a)) => number(a.first()).zip(number(a.get(1))).unwrap_or((0.0, 1.0)), + _ => (0.0, 1.0), + }; + if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) { + return d1 < d0; + } + match d.get(b"ColorSpace").map(|cs| doc.resolve(cs)).as_deref() { + Some(Object::Array(a)) => match a.first().and_then(Object::as_name) { + Some(b"Separation" | b"DeviceN") => d1 > d0, + _ => d1 < d0, + }, + _ => d1 < d0, + } +} + +/// `w` × `h` 1-bit rows (each starting on a new byte) averaged down to `nw` × `nh`, no larger: +/// each new pixel takes the sample that covers more of its area, and the dark one when the two tie +/// (1 if `dark_is_one`, see [`dark_is_one`]), so a dark line half a new pixel wide stays. `None` +/// when the rows are short or the new size isn't smaller in both directions or empty. Also says +/// how many new pixels cover both samples. +pub(crate) fn downsample_bits(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_is_one: bool) -> Option<(Vec, u64)> { + if nw == 0 || nh == 0 || nw > w || nh > h { + return None; + } + let (stride, nstride) = (usize::try_from(w).ok()?.div_ceil(8), usize::try_from(nw).ok()?.div_ceil(8)); + let data = data.get(..stride.checked_mul(usize::try_from(h).ok()?)?)?; + let mut out = vec![0u8; nstride.checked_mul(usize::try_from(nh).ok()?)?]; + // In units of 1/nw of a source column, source column x spans [x·nw, (x+1)·nw) and new column i + // spans [i·w, (i+1)·w); rows likewise with h and nh. A new pixel's area is then w·h. + let (w, h, nw, nh) = (u64::from(w), u64::from(h), u64::from(nw), u64::from(nh)); + let overlap = |a0: u64, a1: u64, b0: u64, b1: u64| a1.min(b1).saturating_sub(a0.max(b0)); + // Per source column, the area its 1 samples cover in the new row. + let mut ones = vec![0u64; usize::try_from(w).ok()?]; + let mut mixed = 0u64; + for j in 0..nh { + ones.fill(0); + let (y0, y1) = (j * h, (j + 1) * h); + for y in y0 / nh..y1.div_ceil(nh).min(h) { + let weight = overlap(y0, y1, y * nh, (y + 1) * nh); + let row = data.get(usize::try_from(y).ok()? * stride..)?; + for (x, sum) in ones.iter_mut().enumerate() { + if row.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0) { + *sum += weight; + } + } + } + let out_row = out.get_mut(usize::try_from(j).ok()? * nstride..)?; + for i in 0..nw { + let (x0, x1) = (i * w, (i + 1) * w); + let covered: u64 = (x0 / nw..x1.div_ceil(nw).min(w)) + .map(|x| overlap(x0, x1, x * nw, (x + 1) * nw) * usize::try_from(x).ok().and_then(|x| ones.get(x)).copied().unwrap_or(0)) + .sum(); + // Against what the 0 samples cover: covered is at most w·h, which can't overflow + // (w·h < 2^64 for u32 sizes). + let zeros = w * h - covered; + mixed += u64::from(covered > 0 && zeros > 0); + if covered > zeros || (dark_is_one && covered == zeros) { + let i = usize::try_from(i).ok()?; + *out_row.get_mut(i / 8)? |= 0x80 >> (i % 8); + } + } + } + Some((out, mixed)) +} + +/// A black-and-white image (see [`one_bit`]) resampled and stored again per `settings`, if that is +/// smaller as stored; `ppi` is its effective resolution, and `defaults` says it is drawn where +/// `/DefaultGray` or `/DefaultRGB` is or may be in force. Also says whether it was resampled. +/// +/// - **Resampled** ([`downsample_bits`]) when its resolution is above `above_ppi`, to `target_ppi`, +/// unless it has a soft mask, whose own size would then have to follow, or is a halftone or a +/// dither: most of its new pixels would cover both black and white, and taking the sample that +/// covers more would turn its tones solid (a 50% screen black, a 25% one white). +/// - **Stored** as CCITT Group 4 with `CcittG4`, or as 1-bit Flate when that is smaller, which only +/// a resampled image or one packed from more bits may be: a 1-bit image that is only stored again +/// becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; Group 3 is stored +/// again like any other. With `Retain`, a resampled image keeps its kind of compression +/// (CCITT, or Flate), and nothing else changes: an image of more bits stays as it is, for the +/// grey or colour settings. +/// +/// The samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for +/// black-and-white RGB, which becomes grey with one decode pair, and an indexed image's default +/// decode array, which goes as the default at one bit. +fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSettings) -> Option<(Stream, bool)> { + let d = &s.dict; + let downsample = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0 && !d.contains(b"SMask"); + let retain = settings.compression == MonoCompression::Retain; + let fax = doc.fax_params(s); + // Its kind of compression, which Retain keeps, behind any other filters. + let ccitt = fax.is_some(); + // Group 4 alone is only worth decoding to resample it. Group 3, or Group 4 behind + // ASCII85Decode, say, can be smaller stored again as Group 4 alone. + let g4_alone = fax.is_some_and(|p| p.k < 0) && s.filters().len() == 1; + if (retain || g4_alone) && !downsample { + return None; + } + let (data, w, h, packed) = one_bit(doc, s, !retain, defaults)?; + let packed_from_more = packed.is_some(); + let k = settings.target_ppi / ppi; + // `downsample_bits` keeps a counter for each column: no more columns than a fax row may have, + // so a hostile strip of 80 million can't make it 640 MB. + let narrow = w <= pdfcraft_filters::MAX_CCITT_COLUMNS; + // At most `w` × `h`, as k < 1: no overflow. + let (nw, nh) = if downsample && narrow && k < 1.0 { + (((f64::from(w) * k).round() as u32).max(1), ((f64::from(h) * k).round() as u32).max(1)) + } else { + (w, h) + }; + let averaged = if (nw, nh) != (w, h) { + // The dark sample of the image as it ends up: packed RGB is grey, with grey's decode pair. + let (out, mixed) = downsample_bits(&data, w, h, nw, nh, dark_is_one(doc, packed.as_ref().unwrap_or(d)))?; + // A halftone or dither: most new pixels cover both samples. Text covers both only along + // its edges. + (mixed.saturating_mul(2) <= u64::from(nw) * u64::from(nh)).then_some(out) + } else { + None + }; + let resampled = averaged.is_some(); + let (data, nw, nh) = match averaged { + Some(out) => (out, nw, nh), + None => (data, w, h), + }; + if (retain || g4_alone) && !resampled { + return None; + } + let mut dict = packed.unwrap_or_else(|| d.clone()); + for key in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] { + dict.remove(key); + } + dict.set(b"Width".to_vec(), Object::Int(i64::from(nw))); + dict.set(b"Height".to_vec(), Object::Int(i64::from(nh))); + let g4 = if settings.compression == MonoCompression::CcittG4 || (retain && ccitt) { + pdfcraft_filters::encode_g4(&data, nw, nh).ok().map(|coded| { + let mut dict = dict.clone(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(nw))); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(nh))); + dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(dict, coded) + }) + } else { + None + }; + let flate = ((resampled || packed_from_more) && !(retain && ccitt)).then(|| Stream::flate(dict, &data)); + // Smaller as stored, counting the dictionary: a tiny image can lose by Group 4's parameters. + let best = [g4, flate].into_iter().flatten().filter_map(|c| Some((crate::stored_len(&c)?, c))).min_by_key(|(len, _)| *len)?; + (best.0 < crate::stored_len(s)?).then_some((best.1, resampled)) +} + pub(crate) fn run( doc: &mut Document, pages: &[ObjRef], @@ -539,7 +1075,12 @@ pub(crate) fn run( report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool, ) -> Result<(), OptimizeError> { - let ppi = effective_resolutions(doc, pages); + let (ppi, mut defaults, read) = walk_pages(doc, pages); + // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise: one used + // where the walk didn't look may be drawn so. + let packs = settings.mono.compression != MonoCompression::Retain; + let rgb: HashSet = ppi.keys().filter(|r| packs && device_rgb(doc, **r)).map(|r| r.num).collect(); + defaults.extend(used_elsewhere(doc, &rgb, &read)); // Soft masks are processed with their image, not on their own. let masks: HashSet = ppi .keys() @@ -558,9 +1099,51 @@ pub(crate) fn run( let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; report.images += 1; - let Some(n) = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)) else { continue }; - let st = if n == 1 { &settings.gray } else { &settings.color }; - let Some((new, smask)) = process(doc, s, ppi, st, n) else { continue }; + // Packed from more bits a sample, it is lossless at one bit, but the grey or colour + // settings can make it smaller still (below). + let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1); + let (packed, mono) = match mono(doc, s, ppi, defaults.contains(&r.num), &settings.mono) { + Some(m) if packed_from_more => (Some(m), None), + m => (None, m), + }; + if let Some((new, resampled)) = mono { + report.image_bytes_before += s.raw.len(); + report.image_bytes_after += new.raw.len(); + if resampled { + report.images_resampled += 1; + } else { + report.images_recompressed += 1; + } + doc.set(r, Object::Stream(new)); + continue; + } + let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)); + let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n)); + // A dithered scan, say, downsampled and JPEG-compressed: the smaller wins, counting a soft + // mask resampled with it. + let smask_len = |smask: Option<&(ObjRef, Stream)>| match smask { + Some((_, m)) => Some(m.raw.len()), + None => s.dict.reference(b"SMask").map(|m| match &*doc.get(m) { + Object::Stream(m) => m.raw.len(), + _ => 0, + }), + }; + let total = |new: &Stream, smask: Option<&(ObjRef, Stream)>| Some(crate::stored_len(new)?.saturating_add(smask_len(smask).unwrap_or(0))); + let packed_wins = packed.as_ref().is_some_and(|(p, _)| { + processed.as_ref().is_none_or(|(new, smask)| total(p, None).zip(total(new, smask.as_ref())).is_some_and(|(a, b)| a <= b)) + }); + if let Some((new, resampled)) = packed.filter(|_| packed_wins) { + report.image_bytes_before += s.raw.len(); + report.image_bytes_after += new.raw.len(); + if resampled { + report.images_resampled += 1; + } else { + report.images_recompressed += 1; + } + doc.set(r, Object::Stream(new)); + continue; + } + let Some((new, smask)) = processed else { continue }; let resized = new.dict.int(b"Width") != s.dict.int(b"Width") || new.dict.int(b"Height") != s.dict.int(b"Height"); report.image_bytes_before += s.raw.len(); report.image_bytes_after += new.raw.len(); @@ -631,3 +1214,136 @@ mod tests { assert!(data.is_none_or(|d| d.len() <= MAX_PAGE_DECODED + 300)); } } + +#[cfg(test)] +mod downsample_tests { + use super::{dark_is_one, downsample_bits}; + use pdfcraft_cos::{Dict, Document, Object}; + + /// [`downsample_bits`] the slow way: each new pixel's area against every source pixel's. + fn naive(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_one: bool) -> Vec { + let (stride, nstride) = ((w as usize).div_ceil(8), (nw as usize).div_ceil(8)); + let (w, h, nw, nh) = (u64::from(w), u64::from(h), u64::from(nw), u64::from(nh)); + let mut out = vec![0u8; nstride * nh as usize]; + for j in 0..nh { + for i in 0..nw { + let mut ones = 0; + for y in 0..h { + for x in 0..w { + let across = ((i + 1) * w).min((x + 1) * nw).saturating_sub((i * w).max(x * nw)); + let down = ((j + 1) * h).min((y + 1) * nh).saturating_sub((j * h).max(y * nh)); + if data[y as usize * stride + x as usize / 8] & (0x80 >> (x % 8)) != 0 { + ones += across * down; + } + } + } + if 2 * ones > w * h || (dark_one && 2 * ones == w * h) { + out[j as usize * nstride + i as usize / 8] |= 0x80 >> (i % 8); + } + } + } + out + } + + #[test] + fn each_new_pixel_takes_the_sample_covering_more_of_it() { + // 4 × 2 to 2 × 1: three 1s of four on the left, none on the right. + assert_eq!(downsample_bits(&[0b1100_0000, 0b1000_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000])); + // A tie gives the dark sample: 0, black in grey, so a black line half a new pixel wide + // stays (the second of four white columns is black); a white one that thin on black doesn't. + assert_eq!(downsample_bits(&[0b1011_0000, 0b1011_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0b0100_0000])); + assert_eq!(downsample_bits(&[0b0100_0000, 0b0100_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0])); + // With 1 the dark sample (an inverting decode array, a separation), that line is a 1. + assert_eq!(downsample_bits(&[0b0100_0000, 0b0100_0000], 4, 2, 2, 1, true).map(|(out, _)| out), Some(vec![0b1000_0000])); + assert_eq!(downsample_bits(&[0b1011_0000, 0b1011_0000], 4, 2, 2, 1, true).map(|(out, _)| out), Some(vec![0b1100_0000])); + // The same size gives the same bits; padding bits are 0. + assert_eq!(downsample_bits(&[0b1010_1111], 3, 1, 3, 1, false).map(|(out, _)| out), Some(vec![0b1010_0000])); + // 3 to 2 across: the middle column counts half to each new one. + assert_eq!(downsample_bits(&[0b1100_0000], 3, 1, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000])); + } + + /// The dark sample: 0 in grey and of an image mask, 1 in a separation, each the other way round + /// with an inverting decode array; 0 for an indexed image, whatever its decode array. + #[test] + fn the_dark_sample_follows_the_colour_space_and_decode_array() { + let doc = Document::new_empty(); + let image = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d + }; + let inverted = ("Decode", Object::Array(vec![1.into(), 0.into()])); + let grey = ("ColorSpace", Object::name("DeviceGray")); + let mask = ("ImageMask", Object::Bool(true)); + let separation = + ("ColorSpace", Object::Array(vec![Object::name("Separation"), Object::name("Spot"), Object::name("DeviceCMYK"), Object::Null])); + let indexed = ("ColorSpace", Object::Array(vec![Object::name("Indexed"), Object::name("DeviceGray"), 1.into(), Object::Null])); + for (entries, dark_one) in [ + (vec![grey.clone()], false), + (vec![grey, inverted.clone()], true), + (vec![mask.clone()], false), + (vec![mask, inverted.clone()], true), + (vec![separation.clone()], true), + (vec![separation, inverted.clone()], false), + (vec![indexed.clone()], false), + (vec![indexed, inverted], true), + (vec![("ColorSpace", Object::name("DeviceGray")), ("Decode", Object::Array(vec![Object::name("x"), 0.into()]))], false), + ] { + assert_eq!(dark_is_one(&doc, &image(&entries)), dark_one, "{entries:?}"); + } + } + + #[test] + fn what_it_cannot_resample_is_none() { + assert_eq!(downsample_bits(&[0; 2], 4, 2, 5, 1, false).map(|(out, _)| out), None, "wider"); + assert_eq!(downsample_bits(&[0; 2], 4, 2, 2, 3, false).map(|(out, _)| out), None, "taller"); + assert_eq!(downsample_bits(&[0; 2], 4, 2, 0, 1, false).map(|(out, _)| out), None); + assert_eq!(downsample_bits(&[0; 1], 4, 2, 2, 1, false).map(|(out, _)| out), None, "short rows"); + assert_eq!(downsample_bits(&[], u32::MAX, u32::MAX, 1, 1, true).map(|(out, _)| out), None); + } + + proptest::proptest! { + /// Any bitmap, to any size no larger, with either sample the dark one, gives what the slow + /// way gives; all 1s stay all 1s. + #[test] + fn it_matches_the_slow_way( + w in 1u32..40, + h in 1u32..16, + nw_part in 0.0f64..1.0, + nh_part in 0.0f64..1.0, + seed in proptest::collection::vec(0u8..=255, 1..64), + dark_one: bool, + ) { + let (nw, nh) = (1 + (f64::from(w - 1) * nw_part) as u32, 1 + (f64::from(h - 1) * nh_part) as u32); + let len = (w as usize).div_ceil(8) * h as usize; + let data: Vec = seed.iter().cycle().take(len).copied().collect(); + proptest::prop_assert_eq!(downsample_bits(&data, w, h, nw, nh, dark_one).map(|(out, _)| out), Some(naive(&data, w, h, nw, nh, dark_one))); + let white = downsample_bits(&vec![0xFF; len], w, h, nw, nh, dark_one).map(|(out, _)| out).unwrap(); + proptest::prop_assert_eq!(white, naive(&vec![0xFF; len], w, h, nw, nh, dark_one)); + proptest::prop_assert!(naive(&vec![0xFF; len], w, h, nw, nh, dark_one).iter().any(|b| *b != 0)); + } + } +} + +#[cfg(test)] +mod pack_tests { + use super::{Pixel, packed_rows}; + + /// Rows of one pixel of one bit, 1 white: each packed as it is. + const WHITE_IS_1: Pixel = |v| Some(v == [1]); + + /// [`packed_rows`] packs rows it can read whole, and refuses any other shape, without reading + /// past a row or allocating more than its samples: no pixels in a row, no components, samples + /// of 3 bits, several components under 8 bits, rows shorter than their pixels, and a width far + /// past the samples. + #[test] + fn packed_rows_refuses_rows_it_cannot_read_whole() { + let samples = [0x80, 0x00, 0x80, 0x00]; + assert_eq!(packed_rows(&samples, 1, 1, 4, (1, 1, WHITE_IS_1)), Some(vec![0x80, 0x00, 0x80, 0x00])); + for (row_len, w, n, bpc) in [(1, 0, 1, 1), (1, 1, 0, 8), (1, 1, 1, 3), (1, 1, 2, 4), (1, 9, 1, 1), (1, usize::MAX / 8, 1, 1)] { + assert_eq!(packed_rows(&samples, row_len, w, 4, (n, bpc, WHITE_IS_1)), None, "row of {row_len} for {w} × {n} × {bpc}"); + } + } +} diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 9f9fcdf2be..3a920d1f13 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -7,10 +7,14 @@ //! - **Images:** each image's effective resolution is measured where pages draw it (the //! smallest over its uses, through form XObjects); colour and grayscale images above a //! threshold are resampled (bicubic) to a target resolution and recompressed as JPEG or Flate. -//! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode -//! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default, -//! JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker and `/ColorTransform` -//! disagree) are left alone. +//! A new image replaces the old one only if it is smaller. The colour and grey settings leave +//! alone images they can't decode faithfully (CMYK and other colour spaces, masks, decode arrays +//! other than the default, JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker +//! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks and CCITT fax, +//! Group 3 among them; and eight-bit grey and RGB of only black and white, and indexed images +//! that use only palette entries 0 and 1, packed to one bit) above a threshold are averaged down to a +//! target resolution, halftones and dithers aside, and are stored as CCITT Group 4, or 1-bit +//! Flate once resampled or packed, when that is smaller. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), //! print settings. //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted @@ -118,6 +122,33 @@ pub enum Compression { Retain, } +/// How black-and-white (1-bit) images are stored. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MonoCompression { + /// CCITT Group 4 (lossless), when smaller than the image as it is. + CcittG4, + /// Keep each image's own compression. + Retain, +} + +/// How black-and-white images (1-bit, CCITT fax among them; and eight-bit grey and RGB of only black +/// and white, and indexed images that use only palette entries 0 and 1, packed to one bit), and +/// image masks, are resampled and stored. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MonoSettings { + /// Average images whose effective resolution is above `above_ppi` down to `target_ppi`. + pub downsample: bool, + pub target_ppi: f64, + pub above_ppi: f64, + pub compression: MonoCompression, +} + +impl MonoSettings { + /// Reduce File Size and the presets: to 300 ppi above 450 ppi, CCITT Group 4. Text in a scan + /// stays sharp at 300 ppi. + pub const REDUCE: MonoSettings = MonoSettings { downsample: true, target_ppi: 300.0, above_ppi: 450.0, compression: MonoCompression::CcittG4 }; +} + /// Acrobat's JPEG quality steps. pub const QUALITIES: [(&str, u8); 5] = [("Minimum", 25), ("Low", 45), ("Medium", 60), ("High", 80), ("Maximum", 95)]; @@ -147,6 +178,8 @@ pub struct Settings { pub merge_fragments: bool, pub color: ImageSettings, pub gray: ImageSettings, + /// Black-and-white images, and image masks. + pub mono: MonoSettings, pub discard_thumbnails: bool, pub discard_alternate_images: bool, pub discard_tags: bool, @@ -181,6 +214,7 @@ impl Default for Settings { merge_fragments: true, color: ImageSettings::REDUCE, gray: ImageSettings::REDUCE, + mono: MonoSettings::REDUCE, discard_thumbnails: true, discard_alternate_images: true, discard_tags: false, @@ -539,7 +573,7 @@ fn strip_ascii_wrapper(s: &Stream) -> Option { /// Bytes the stream object takes in the file: its dictionary with `/Length` set as the writer /// sets it, then its data. -fn stored_len(s: &Stream) -> Option { +pub(crate) fn stored_len(s: &Stream) -> Option { let mut dict = s.dict.clone(); dict.set(b"Length".to_vec(), Object::Int(i64::try_from(s.raw.len()).ok()?)); let mut out = Vec::new(); diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 3e69e27cb9..d8fe601a39 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -5,7 +5,8 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; use pdfcraft_filters::{Filter, Params}; use pdfcraft_optimize::{ - Compatibility, Compression, ImageSettings, OptimizeError, Settings, SpaceCategory, audit_space, effective_resolutions, optimize, + Compatibility, Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Settings, SpaceCategory, audit_space, + effective_resolutions, optimize, }; fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option) -> ObjRef { @@ -218,6 +219,1698 @@ fn an_identity_decode_array_does_not_stop_reduce() { assert_eq!(stream(&doc, odd).dict.int(b"Width"), Some(900)); } +/// Rows of `w` 1-bit samples (1 white, 0 black), each starting on a byte: lines of text-like +/// glyphs; or, with `noise`, random bits. +fn bilevel(w: u32, h: u32, noise: bool) -> Vec { + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let mut black = |x: usize, y: usize| { + if x < w as usize && y < h as usize { + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + }; + if noise { + for y in 0..h as usize { + for x in 0..w as usize { + if next() & 1 == 0 { + black(x, y); + } + } + } + return data; + } + // Lines 24 rows apart, of glyphs drawn from a few shapes (a stem, a bowl, an arch, a dotted + // stem) at any column, 1–3 columns apart with a wider gap between words: text as a scanner + // sees it. Clean, identical rows would favour Flate; a scan's ragged edges favour Group 4. + for line in (4..h as usize).step_by(24) { + let mut x = 8; + while x + 16 < w as usize { + let wide = 5 + (next() % 4) as usize; + let shape = next() % 4; + // Now and then a row of a stroke is a column wider or narrower at an edge, as a scan's + // edges are. + let mut ragged = || match next() % 8 { + 0 => 0, + 1 => 2, + _ => 1, + }; + let mut fill = |x0: usize, x1: usize, y0: usize, y1: usize| { + for y in line + y0..line + y1 { + let (left, right) = ((x + x0 + 1).saturating_sub(ragged()), x + x1 + ragged()); + for xx in left..right.saturating_sub(1) { + black(xx, y); + } + } + }; + match shape { + 0 => fill(0, 2, 0, 14), + 1 => { + fill(0, wide, 4, 6); + fill(0, wide, 12, 14); + fill(0, 2, 4, 14); + fill(wide - 2, wide, 4, 14); + } + 2 => { + fill(0, 2, 4, 14); + fill(wide - 2, wide, 6, 14); + fill(0, wide - 1, 4, 6); + } + _ => { + fill(0, 2, 7, 14); + fill(0, 2, 2, 4); + } + } + x += wide + 2 + (next() % 2) as usize + if next() % 6 == 0 { 7 } else { 0 }; + } + } + data +} + +/// A 1-bit image of `data`, Flate-compressed (with `predictor`, a PNG predictor, if any), with +/// `entries` added to its dictionary. +fn mono_image(doc: &mut Document, w: u32, h: u32, data: &[u8], predictor: Option, entries: &[(&str, Object)]) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + let s = match predictor { + None => Stream::flate(d, data), + Some(predictor) => { + let params = pdfcraft_filters::Params { predictor, colors: 1, bits_per_component: 1, columns: w as i64, early_change: 1 }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(predictor)); + parms.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + parms.set(b"Columns".to_vec(), Object::Int(w as i64)); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(d, pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, data).unwrap()) + } + }; + doc.add(Object::Stream(s)) +} + +fn render(doc: &Document) -> Vec { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.rgba.to_vec() +} + +/// Black-and-white images become CCITT Group 4, losslessly: the page looks the same to the pixel. +/// Grey, an image mask (with a decode array that inverts it), an indexed image of two colours and +/// one stored with a PNG predictor qualify; noise (bigger as G4), a 1-bit RGB image, one whose +/// colour space is a resource name, one wider than Group 4 encodes and a tiny one (bigger as +/// stored) are left alone, as is everything with `mono: Retain`. +#[test] +fn black_and_white_images_become_ccitt_group_4() { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let text = bilevel(w, h, false); + let gray = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let decode = Object::Array(vec![1.into(), 0.into()]); + let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]); + let indexed_cs = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(vec![0xC0, 0x10, 0x10, 0xF8, 0xF0, 0xE0])), + ]); + let indexed = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", indexed_cs.clone())]); + let predicted = mono_image(&mut doc, w, h, &text, Some(15), &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let noise = + mono_image(&mut doc, 400, 200, &bilevel(400, 200, true), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let rgb = mono_image( + &mut doc, + 400, + 160, + &bilevel(1200, 160, false), + None, + &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceRGB"))], + ); + let wide = mono_image( + &mut doc, + 40_000, + 2, + &bilevel(40_000, 2, false), + None, + &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))], + ); + // A bare /Indexed is no colour space on its own: it names the page's resource, here RGB, so + // these samples are three to a pixel. + let named = + mono_image(&mut doc, 400, 160, &bilevel(1200, 160, false), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("Indexed"))]); + // 64 × 8 white: its Group 4 data is smaller than its Flate data, but not by the longer + // dictionary Group 4 needs. + let tiny = mono_image(&mut doc, 64, 8, &[0xFF; 64], None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let tiny_g4 = pdfcraft_filters::encode_g4(&[0xFF; 64], 64, 8).unwrap(); + assert!(tiny_g4.len() < stream(&doc, tiny).raw.len(), "{} against {}", tiny_g4.len(), stream(&doc, tiny).raw.len()); + let p = page( + &mut doc, + &[("G", gray), ("M", mask), ("I", indexed), ("P", predicted), ("N", noise), ("R", rgb), ("W", wide), ("T", tiny), ("X", named)], + "q 300 0 0 120 20 660 cm /G Do Q q 0 0.4 0.8 rg 300 0 0 120 320 660 cm /M Do Q q 300 0 0 120 20 520 cm /I Do Q \ + q 300 0 0 120 320 520 cm /P Do Q q 200 0 0 100 20 400 cm /N Do Q q 200 0 0 80 240 400 cm /R Do Q q 560 0 0 4 20 380 cm /W Do Q q 64 0 0 8 460 400 cm /T Do Q q 200 0 0 80 240 300 cm /X Do Q", + ); + doc.update_dict(p, |d| { + if let Some(Object::Dict(res)) = d.get_mut(b"Resources") { + let mut cs = Dict::new(); + cs.set(b"Indexed".to_vec(), Object::name("DeviceRGB")); + res.set(b"ColorSpace".to_vec(), Object::Dict(cs)); + } + }) + .unwrap(); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + let untouched = [noise, rgb, wide, tiny, named].map(|r| raw(&doc, r)); + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!(r.images_recompressed, 0, "Retain keeps every image as it is"); + assert!([gray, mask, indexed, predicted].iter().all(|i| raw(&kept, *i) == raw(&doc, *i))); + + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_recompressed, 4); + assert!(r.image_bytes_after < r.image_bytes_before, "{} → {}", r.image_bytes_before, r.image_bytes_after); + for i in [gray, mask, indexed, predicted] { + let s = stream(&doc, i); + assert_eq!(s.dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + let parms = s.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!((parms.int(b"K"), parms.int(b"Columns"), parms.int(b"Rows")), (Some(-1), Some(w as i64), Some(h as i64))); + } + assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + assert_eq!(stream(&doc, indexed).dict.get(b"ColorSpace"), Some(&indexed_cs), "the colour space stays"); + assert_eq!([noise, rgb, wide, tiny, named].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// Which 1-bit images qualify for Group 4: those of one colour component, an ICC profile of one +/// component and a DeviceN of one colourant among them, but not one of three components or two +/// colourants (more than a bit a pixel); image masks of one bit, as `/BitsPerComponent 1` says or +/// by default, but not one that says 8; and images of up to 80 million pixels, not one row more. +/// The byte totals count exactly the images stored again. +#[test] +fn group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let icc = |doc: &mut Document, n: i64| { + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Int(n)); + Object::Array(vec![Object::name("ICCBased"), Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, Vec::new()))))]) + }; + let device_n = |colourants: &[&str]| { + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), Object::Array(vec![0.into(), 1.into()])); + tint.set(b"N".to_vec(), Object::Int(1)); + Object::Array(vec![ + Object::name("DeviceN"), + Object::Array(colourants.iter().map(|c| Object::name(c)).collect()), + Object::name("DeviceGray"), + Object::Dict(tint), + ]) + }; + let one_bit = |cs: Object| [("BitsPerComponent", 1.into()), ("ColorSpace", cs)]; + let icc_1 = icc(&mut doc, 1); + let icc_1 = mono_image(&mut doc, w, h, &text, None, &one_bit(icc_1)); + let icc_3 = icc(&mut doc, 3); + let icc_3 = mono_image(&mut doc, w, h, &bilevel(3 * w, h, false), None, &one_bit(icc_3)); + let spot = mono_image(&mut doc, w, h, &text, None, &one_bit(device_n(&["Spot"]))); + let two_spots = mono_image(&mut doc, w, h, &bilevel(2 * w, h, false), None, &one_bit(device_n(&["Spot", "Other"]))); + let mask = |bits: i64| [("ImageMask", Object::Bool(true)), ("BitsPerComponent", bits.into())]; + let mask_1 = mono_image(&mut doc, w, h, &text, None, &mask(1)); + let mask_8 = mono_image(&mut doc, w, h, &bilevel(8 * w, h, false), None, &mask(8)); + // 10,000 × 8,000 is the limit itself. + let grey = one_bit(Object::name("DeviceGray")); + let at_limit = mono_image(&mut doc, 10_000, 8_000, &bilevel(10_000, 8_000, false), None, &grey); + let past_limit = mono_image(&mut doc, 10_000, 8_001, &bilevel(10_000, 8_001, false), None, &grey); + let all = [icc_1, icc_3, spot, two_spots, mask_1, mask_8, at_limit, past_limit]; + let names: Vec = (0..all.len()).map(|i| format!("Im{i}")).collect(); + // 200 × 80 pt is 432 ppi, 1800 × 1440 pt 400 ppi: below where monochrome downsampling starts. + let content: String = (0..all.len()) + .map(|i| { + if i < 6 { + format!("q 200 0 0 80 {} {} cm /Im{i} Do Q ", 6 + 200 * (i % 3), 690 - 100 * (i / 3)) + } else { + format!("q 1800 0 0 1440 0 0 cm /Im{i} Do Q ") + } + }) + .collect(); + page(&mut doc, &names.iter().map(String::as_str).zip(all).collect::>(), &content); + let converted = [icc_1, spot, mask_1, at_limit]; + let left = [icc_3, two_spots, mask_8, past_limit]; + let before = |doc: &Document, refs: &[ObjRef]| refs.iter().map(|r| stream(doc, *r).raw).collect::>(); + let (old, untouched) = (before(&doc, &converted), before(&doc, &left)); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_recompressed, converted.len(), "{r:?}"); + for i in converted { + assert_eq!(stream(&doc, i).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "{i:?}"); + } + assert_eq!(before(&doc, &left), untouched); + let new = before(&doc, &converted); + assert_eq!((r.image_bytes_before, r.image_bytes_after), (old.iter().map(|b| b.len()).sum(), new.iter().map(|b| b.len()).sum())); +} + +/// An image whose /DecodeParms are a reference, or hold numbers given by reference +/// (`/Predictor 9 0 R`, which `Dict::int` doesn't follow), is read through them, as one with them +/// direct: a predictor's tag bytes would otherwise shift every row, and the image would be stored +/// again wrong. Here a 1-bit image with a PNG predictor (stored again as Group 4), an eight-bit +/// grey one with a PNG predictor at 600 ppi (resampled), an eight-bit one of only black and white +/// with PNG predictor 10 (each row's tag a 0, so the shifted rows would still be black and white; +/// packed to one bit), and a two-entry indexed one the same way come out byte for byte as with +/// direct parameters. +#[test] +fn images_whose_decode_parms_are_references_come_out_as_with_them_direct() { + let build = |how: &str| { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let one_bit = mono_image(&mut doc, w, h, &bilevel(w, h, false), Some(15), &grey); + let black_and_white_samples = eight_bit(&bilevel(w, h, false), w, h); + let shades: Vec = (0..w * h).map(|i| ((i % w) * 255 / w) as u8 ^ ((i / w) as u8 & 0x0F)).collect(); + let params = pdfcraft_filters::Params { predictor: 15, colors: 1, bits_per_component: 8, columns: w as i64, early_change: 1 }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(15)); + parms.set(b"Columns".to_vec(), Object::Int(w as i64)); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let coded = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &shades).unwrap(); + let eight_bit = doc.add(Object::Stream(Stream::from_raw(d.clone(), coded))); + let params = pdfcraft_filters::Params { predictor: 10, ..params }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(10)); + parms.set(b"Columns".to_vec(), Object::Int(w as i64)); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let black_and_white = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &black_and_white_samples).unwrap(); + let packed = doc.add(Object::Stream(Stream::from_raw(d.clone(), black_and_white))); + let palette = vec![0, 0, 255, 255, 255, 255]; + d.set( + b"ColorSpace".to_vec(), + Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(palette)), + ]), + ); + let entries = samples_of(&bilevel(w, h, false), w, h, [&[0], &[1]], 8); + let entries = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &entries).unwrap(); + let indexed = doc.add(Object::Stream(Stream::from_raw(d, entries))); + for r in [one_bit, eight_bit, packed, indexed] { + let mut s = stream(&doc, r); + let Some(Object::Dict(mut parms)) = s.dict.get(b"DecodeParms").cloned() else { panic!("{:?}", s.dict) }; + let made = match how { + "whole" => Object::Ref(doc.add(Object::Dict(parms))), + "numbers" => { + for k in [&b"Predictor"[..], b"Columns"] { + let v = parms.get(k).cloned().unwrap(); + parms.set(k.to_vec(), Object::Ref(doc.add(v))); + } + Object::Dict(parms) + } + _ => continue, + }; + s.dict.set(b"DecodeParms".to_vec(), made); + doc.set(r, Object::Stream(s)); + } + // 300 × 120 pt is 288 ppi; 144 × 57.6 pt is 600 ppi. + page( + &mut doc, + &[("A", one_bit), ("B", eight_bit), ("C", packed), ("D", indexed)], + "q 300 0 0 120 20 600 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q \ + q 280 0 0 112 325 200 cm /D Do Q", + ); + let before = [one_bit, eight_bit, packed, indexed].map(|r| stream(&doc, r).raw); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (1, 3), "{r:?}"); + let after = [one_bit, eight_bit, packed, indexed].map(|r| stream(&doc, r)); + assert!(before.iter().zip(&after).all(|(b, a)| *b != a.raw), "all stored again"); + assert_eq!(after[0].dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + for s in &after[2..] { + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..]))); + } + after.map(|s| s.raw) + }; + let direct = build("direct"); + assert_eq!(build("whole"), direct, "the dictionary by reference"); + assert_eq!(build("numbers"), direct, "the numbers inside by reference"); +} + +/// An image whose data decodes far past what its samples can need (a decompression bomb) is left +/// alone rather than decoded up to the 1 GiB limit: a 1-bit one that would become Group 4, an +/// eight-bit one that would be resampled, and an eight-bit one of only black and white that would +/// be packed to one bit, each with four megabytes of zeros after its samples. Stored as ASCIIHex +/// text under Flate, broken into lines of 64 digits, their samples aren't a bomb. +#[test] +fn images_whose_data_decodes_far_past_their_samples_are_left_alone() { + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let shades: Vec = (0..w * h).map(|i| ((i % w) * 255 / w) as u8 ^ ((i / w) as u8 & 0x0F)).collect(); + for padding in [0, 4 << 20] { + let mut doc = Document::new_empty(); + let padded = |data: &[u8]| data.iter().copied().chain(std::iter::repeat_n(0, padding)).collect::>(); + let grey = |bits: i64| [("BitsPerComponent", bits.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let one_bit = mono_image(&mut doc, w, h, &padded(&text), None, &grey(1)); + let eight_bit = mono_image(&mut doc, w, h, &padded(&shades), None, &grey(8)); + let packed = mono_image(&mut doc, w, h, &padded(&self::eight_bit(&text, w, h)), None, &grey(8)); + // 300 × 120 pt is 288 ppi; 144 × 57.6 pt is 600 ppi. + page( + &mut doc, + &[("A", one_bit), ("B", eight_bit), ("C", packed)], + "q 300 0 0 120 20 600 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q", + ); + let before = [one_bit, eight_bit, packed].map(|r| stream(&doc, r).raw); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + let after = [one_bit, eight_bit, packed].map(|r| stream(&doc, r).raw); + if padding == 0 { + assert_eq!((r.images_resampled, r.images_recompressed), (1, 2), "{r:?}"); + } else { + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(after, before); + } + } + let hex_lines = |data: &[u8]| { + let digits: Vec = data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + let mut text: Vec = digits.chunks(64).flat_map(|line| line.iter().copied().chain(*b"\n")).collect(); + text.push(b'>'); + pdfcraft_filters::encode_flate(&text) + }; + let mut doc = Document::new_empty(); + let as_hex = |doc: &mut Document, data: &[u8], bits: i64, (w, h): (u32, u32)| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(bits)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + doc.add(Object::Stream(Stream::from_raw(d, hex_lines(data)))) + }; + // Large enough that its hex text in lines is more than twice its samples and a byte a row + // plus 4 KiB: 2560 × 1200, drawn at 400 ppi. + let page_scan = bilevel(2560, 1200, false); + let one_bit = as_hex(&mut doc, &page_scan, 1, (2560, 1200)); + let eight_bit = as_hex(&mut doc, &shades, 8, (w, h)); + let packed = as_hex(&mut doc, &self::eight_bit(&text, w, h), 8, (w, h)); + page( + &mut doc, + &[("A", one_bit), ("B", eight_bit), ("C", packed)], + "q 460.8 0 0 216 20 500 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q", + ); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, one_bit).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "1-bit hex text in lines: Group 4"); + assert!(stream(&doc, eight_bit).dict.int(b"Width").is_some_and(|nw| nw < i64::from(w)), "8-bit hex text in lines: resampled"); + assert_eq!(stream(&doc, packed).dict.int(b"BitsPerComponent"), Some(1), "8-bit black-and-white hex text in lines: packed"); +} + +/// Monochrome Images keeping each image's compression (and resampling as by default). +const RETAIN: MonoSettings = MonoSettings { compression: MonoCompression::Retain, ..MonoSettings::REDUCE }; + +/// Eight-bit samples (0 or 255) from rows of one-bit ones. +fn eight_bit(bits: &[u8], w: u32, h: u32) -> Vec { + let stride = (w as usize).div_ceil(8); + (0..h as usize).flat_map(|y| (0..w as usize).map(move |x| if bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0 { 255 } else { 0 })).collect() +} + +/// Eight-bit samples of only 0 and 255, dithered (Floyd–Steinberg) from a smooth picture as a +/// scanner's halftone mode gives them: no shade but black and white, in short runs. +fn dithered(w: u32, h: u32) -> Vec { + let (w, h) = (w as usize, h as usize); + let mut level: Vec = (0..w * h).map(|i| 127.5 + 127.5 * ((i % w) as f32 / 37.0).sin() * ((i / w) as f32 / 53.0).cos()).collect(); + let mut out = vec![0u8; w * h]; + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let v = if level[i] < 128.0 { 0.0 } else { 255.0 }; + out[i] = v as u8; + let e = level[i] - v; + if x + 1 < w { + level[i + 1] += e * 7.0 / 16.0; + } + if y + 1 < h { + if x > 0 { + level[i + w - 1] += e * 3.0 / 16.0; + } + level[i + w] += e * 5.0 / 16.0; + if x + 1 < w { + level[i + w + 1] += e / 16.0; + } + } + } + } + out +} + +/// A dithered scan stored as eight-bit grey of only black and white is smaller downsampled and +/// JPEG-compressed by the grey settings than packed to one bit, which can't shrink its dots: the +/// smaller wins, so Reduce File Size makes it no larger than with Monochrome Images on Retain. +/// Clean text is still packed to one bit at the same resolution. +#[test] +fn eight_bit_black_and_white_takes_the_smaller_of_one_bit_and_the_grey_settings() { + let (w, h) = (1200u32, 1500u32); + for (what, samples) in [("dither", dithered(w, h)), ("text", eight_bit(&bilevel(w, h, false), w, h))] { + let build = || { + let mut doc = Document::new_empty(); + let img = mono_image(&mut doc, w, h, &samples, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]); + // 288 × 360 pt is 300 ppi, above where the grey settings downsample. + page(&mut doc, &[("A", img)], "q 288 0 0 360 20 400 cm /A Do Q"); + (doc, img) + }; + let (mut doc, img) = build(); + optimize(&mut doc, &Settings::default()).unwrap(); + let (mut retained, _) = build(); + optimize(&mut retained, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + let (s, kept) = (stream(&doc, img), stream(&retained, img)); + let bits = if what == "dither" { 8 } else { 1 }; + assert_eq!(s.dict.int(b"BitsPerComponent"), Some(bits), "{what}: {} bytes, {} with Retain", s.raw.len(), kept.raw.len()); + if what == "dither" { + assert!(s.raw.len() <= kept.raw.len(), "{what}: {} bytes, {} with Retain", s.raw.len(), kept.raw.len()); + } + } +} + +/// The smaller of one bit and the grey settings counts the soft mask each leaves: packed, the +/// image keeps its mask as it is; resampled by the grey settings, the mask is resampled with it. +/// Clean text, 1200 × 1500 at 300 ppi, under a noisy mask. A mask as large: the grey settings +/// shrink it by far more than packing saves on the image, and win. A mask of 640 × 800 (a soft +/// mask may be another size than its image): resampled to the image's new size it shrinks only a +/// little, by less than the grey settings' JPEG adds over the packed image, and packing wins. +#[test] +fn eight_bit_black_and_white_with_a_soft_mask_counts_the_mask() { + let (w, h) = (1200u32, 1500u32); + for ((mw, mh), grey_wins) in [((w, h), true), ((640, 800), false)] { + let mut doc = Document::new_empty(); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let noise: Vec = (0..mw * mh) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 24) as u8 + }) + .collect(); + let mask = mono_image(&mut doc, mw, mh, &noise, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let grey = [("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray")), ("SMask", Object::Ref(mask))]; + let img = mono_image(&mut doc, w, h, &eight_bit(&bilevel(w, h, false), w, h), None, &grey); + page(&mut doc, &[("A", img)], "q 288 0 0 360 20 400 cm /A Do Q"); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, img); + let expected = if grey_wins { (Some(8), Some(&b"DCTDecode"[..])) } else { (Some(1), Some(&b"CCITTFaxDecode"[..])) }; + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), expected, "mask {mw} × {mh}: {:?}", s.dict); + } +} + +/// Eight-bit grey images of only black and white (as some scanners and converters write scans) +/// become one-bit Group 4, losslessly: the page looks the same, an inverting decode array +/// included. Not touched by this: a grey image with any other shade, an indexed image (its samples +/// are palette indices), one masked by colour key, and everything with `mono: Retain`. +#[test] +fn eight_bit_black_and_white_images_become_one_bit() { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let grey = eight_bit(&bilevel(w, h, false), w, h); + let image = |doc: &mut Document, data: &[u8], entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let gray_cs = ("ColorSpace", Object::name("DeviceGray")); + let plain = image(&mut doc, &grey, std::slice::from_ref(&gray_cs)); + let decode = Object::Array(vec![1.into(), 0.into()]); + let inverted = image(&mut doc, &grey, &[gray_cs.clone(), ("Decode", decode.clone())]); + let mut shaded = grey.clone(); + shaded[1000] = 128; + let shade = image(&mut doc, &shaded, std::slice::from_ref(&gray_cs)); + let palette = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceGray"), + 255.into(), + Object::String(pdfcraft_cos::PdfString::literal((0..=255u8).rev().collect::>())), + ]); + let indexed = image(&mut doc, &grey, &[("ColorSpace", palette)]); + let keyed = image(&mut doc, &grey, &[gray_cs, ("Mask", Object::Array(vec![0.into(), 0.into()]))]); + page( + &mut doc, + &[("A", plain), ("B", inverted), ("C", shade), ("D", indexed), ("E", keyed)], + "q 300 0 0 120 20 660 cm /A Do Q q 300 0 0 120 320 660 cm /B Do Q q 300 0 0 120 20 520 cm /C Do Q \ + q 300 0 0 120 320 520 cm /D Do Q q 300 0 0 120 20 380 cm /E Do Q", + ); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + // Grey settings that leave grey images alone, so only this conversion acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let settings = Settings { gray: keep, ..Settings::default() }; + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..settings.clone() }).unwrap(); + assert_eq!(r.images_recompressed, 0, "Retain keeps every image as it is"); + + let untouched = [shade, indexed, keyed].map(|r| raw(&doc, r)); + let old: usize = [plain, inverted].map(|r| raw(&doc, r).len()).iter().sum(); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_recompressed, 2); + let new: usize = [plain, inverted].map(|r| raw(&doc, r).len()).iter().sum(); + assert_eq!((r.image_bytes_before, r.image_bytes_after), (old, new), "the byte totals count both"); + for i in [plain, inverted] { + let s = stream(&doc, i); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..]))); + } + assert_eq!(stream(&doc, inverted).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + assert_eq!([shade, indexed, keyed].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// `bits` (1-bit rows) with each 0 as `values[0]` and each 1 as `values[1]`, at `bpc` bits a sample +/// (each row starting on a new byte). +fn samples_of(bits: &[u8], w: u32, h: u32, values: [&[u8]; 2], bpc: usize) -> Vec { + let (w, h) = (w as usize, h as usize); + let stride = w.div_ceil(8); + let mut out = Vec::new(); + for y in 0..h { + let row: Vec = (0..w).flat_map(|x| values[usize::from(bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0)].iter().copied()).collect(); + let mut packed = vec![0u8; (row.len() * bpc).div_ceil(8)]; + for (i, s) in row.iter().enumerate() { + packed[i * bpc / 8] |= if bpc == 8 { *s } else { s << (8 - bpc - i * bpc % 8) }; + } + out.extend(packed); + } + out +} + +/// Eight-bit RGB of only black and white becomes one-bit grey, and an indexed image that uses only +/// palette entries 0 and 1 becomes one bit with its palette (here blue and white), at 8 bits or 4, +/// both as Group 4 and losslessly: the page looks the same to the pixel. An RGB decode array the +/// same for each component becomes grey's, and an indexed image's default one goes. Not touched: +/// RGB with any other colour, a decode array that differs between components, a colour-key mask or +/// calibrated RGB; an indexed image that uses a third entry or has another decode array; and +/// everything with `mono: Retain`. +#[test] +fn rgb_and_two_colour_indexed_black_and_white_images_become_one_bit() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], bpc: i64, cs: Object, entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(bpc)); + d.set(b"ColorSpace".to_vec(), cs); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let rgb_cs = Object::name("DeviceRGB"); + let rgb_data = samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let rgb = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[]); + let pairs = |p: [i64; 6]| Object::Array(p.into_iter().map(Object::Int).collect()); + let rgb_inverted = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[("Decode", pairs([1, 0, 1, 0, 1, 0]))]); + let rgb_mixed = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[("Decode", pairs([1, 0, 0, 1, 0, 1]))]); + let mut red = rgb_data.clone(); + red[3000..3003].copy_from_slice(&[255, 0, 0]); + let rgb_red = image(&mut doc, &red, 8, rgb_cs.clone(), &[]); + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let cal_rgb = image(&mut doc, &rgb_data, 8, Object::Array(vec![Object::name("CalRGB"), Object::Dict(white_point)]), &[]); + let rgb_keyed = image(&mut doc, &rgb_data, 8, rgb_cs, &[("Mask", pairs([0, 0, 0, 0, 0, 0]))]); + let palette = |entries: &[u8]| { + Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + (entries.len() as i64 / 3 - 1).into(), + Object::String(pdfcraft_cos::PdfString::literal(entries.to_vec())), + ]) + }; + let blue_white = palette(&[0, 0, 255, 255, 255, 255]); + let indices = samples_of(&text, w, h, [&[0], &[1]], 8); + let blue = image(&mut doc, &indices, 8, blue_white.clone(), &[]); + let default_decode = Object::Array(vec![0.into(), 255.into()]); + let blue_decoded = image(&mut doc, &indices, 8, blue_white.clone(), &[("Decode", default_decode)]); + let blue_4 = image(&mut doc, &samples_of(&text, w, h, [&[0], &[1]], 4), 4, blue_white.clone(), &[]); + let mut third = indices.clone(); + third[3000] = 2; + let three = image(&mut doc, &third, 8, palette(&[0, 0, 255, 255, 255, 255, 255, 0, 0]), &[]); + let other_decode = image(&mut doc, &indices, 8, blue_white.clone(), &[("Decode", Object::Array(vec![0.into(), 1.into()]))]); + let all = [rgb, rgb_inverted, rgb_mixed, rgb_red, cal_rgb, rgb_keyed, blue, blue_decoded, blue_4, three, other_decode]; + // 200 × 80 pt is 432 ppi: below where monochrome downsampling starts. + let names: Vec = (0..all.len()).map(|i| format!("Im{i}")).collect(); + let content: String = + (0..all.len()).map(|i| format!("q 200 0 0 80 {} {} cm /Im{i} Do Q ", 6 + 200 * (i % 3), 690 - 100 * (i / 3) as i64)).collect(); + page(&mut doc, &names.iter().map(String::as_str).zip(all).collect::>(), &content); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + // Colour and grey settings that leave images alone, so only this conversion acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let settings = Settings { color: keep, gray: keep, ..Settings::default() }; + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..settings.clone() }).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "Retain keeps every image as it is"); + + let untouched = [rgb_mixed, rgb_red, cal_rgb, rgb_keyed, three, other_decode].map(|r| raw(&doc, r)); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 5), "{r:?}"); + for (i, cs) in [ + (rgb, Object::name("DeviceGray")), + (rgb_inverted, Object::name("DeviceGray")), + (blue, blue_white.clone()), + (blue_decoded, blue_white.clone()), + (blue_4, blue_white), + ] { + let s = stream(&doc, i); + assert_eq!( + (s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter"), s.dict.get(b"ColorSpace")), + (Some(1), Some(&b"CCITTFaxDecode"[..]), Some(&cs)), + "{i:?}" + ); + assert_eq!(doc.decoded_image(&s).unwrap(), text, "{i:?}"); + let decode = if i == rgb_inverted { Some(Object::Array(vec![1.into(), 0.into()])) } else { None }; + assert_eq!(s.dict.get(b"Decode").cloned(), decode, "{i:?}"); + } + assert_eq!([rgb_mixed, rgb_red, cal_rgb, rgb_keyed, three, other_decode].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// Eight-bit black-and-white images in other one-component spaces are packed too: calibrated +/// grey, an ICC profile of one component, a separation, each keeping its colour space. Calibrated +/// RGB whose samples are only 0 and 1 is no indexed image, and stays as it is. +#[test] +fn eight_bit_black_and_white_in_other_one_component_spaces_is_packed() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], cs: Object| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), cs); + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let mut profile = Dict::new(); + profile.set(b"N".to_vec(), Object::Int(1)); + let profile = doc.add(Object::Stream(Stream::from_raw(profile, Vec::new()))); + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), Object::Array(vec![0.into(), 1.into()])); + tint.set(b"N".to_vec(), Object::Int(1)); + let spaces = [ + Object::Array(vec![Object::name("CalGray"), Object::Dict(white_point.clone())]), + Object::Array(vec![Object::name("ICCBased"), Object::Ref(profile)]), + Object::Array(vec![Object::name("Separation"), Object::name("Spot"), Object::name("DeviceGray"), Object::Dict(tint)]), + ]; + let grey = samples_of(&text, w, h, [&[0], &[255]], 8); + let packed = spaces.clone().map(|cs| image(&mut doc, &grey, cs)); + let cal_rgb = Object::Array(vec![Object::name("CalRGB"), Object::Dict(white_point)]); + let cal_rgb = image(&mut doc, &samples_of(&text, w, h, [&[0, 0, 0], &[1, 1, 1]], 8), cal_rgb); + let names = ["A", "B", "C", "D"]; + // 200 × 80 pt is 432 ppi: below where monochrome downsampling starts. + let content: String = (0..4).map(|i| format!("q 200 0 0 80 6 {} cm /{} Do Q ", 690 - 100 * i, names[i])).collect(); + page(&mut doc, &names.into_iter().zip([packed[0], packed[1], packed[2], cal_rgb]).collect::>(), &content); + let untouched = stream(&doc, cal_rgb).raw; + // Colour and grey settings that leave images alone, so only the packing acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + optimize(&mut doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap(); + for (r, cs) in packed.iter().zip(&spaces) { + let s = stream(&doc, *r); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"ColorSpace")), (Some(1), Some(cs)), "{cs:?}"); + } + assert_eq!(stream(&doc, cal_rgb).raw, untouched, "calibrated RGB"); +} + +/// A resampled Group 4 image is stored as 1-bit Flate when that is smaller, as any resampled image +/// may be: here vertical stripes, whose identical rows Flate takes for almost nothing. +#[test] +fn a_resampled_group_4_image_is_stored_as_flate_when_smaller() { + let (w, h) = (1200u32, 480u32); + let stripes = vec![0x0Fu8; (w as usize).div_ceil(8) * h as usize]; + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let img = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_g4(&stripes, w, h).unwrap()))); + // 144 × 57.6 pt is 600 ppi. + page(&mut doc, &[("A", img)], "q 144 0 0 57.6 20 600 cm /A Do Q"); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_resampled, 1, "{r:?}"); + let s = stream(&doc, img); + assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(600), Some(&b"FlateDecode"[..]))); +} + +/// A decode array is read as numbers, whichever way they are written: an indexed image's default +/// as `[0 255.0]` or with an entry by reference, and RGB's inverting pairs with a real among them, +/// pack as when written as integers. +#[test] +fn decode_arrays_read_as_numbers_however_written() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], cs: Object, decode: Object| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), cs); + d.set(b"Decode".to_vec(), decode); + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let palette = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 255, 255, 255, 255])), + ]); + let indices = samples_of(&text, w, h, [&[0], &[1]], 8); + let max = doc.add(Object::Int(255)); + let real = image(&mut doc, &indices, palette.clone(), Object::Array(vec![0.into(), Object::Real(255.0)])); + let referred = image(&mut doc, &indices, palette, Object::Array(vec![0.into(), Object::Ref(max)])); + let rgb = samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let pairs = Object::Array(vec![1.into(), 0.into(), 1.into(), 0.into(), Object::Real(1.0), 0.into()]); + let inverted = image(&mut doc, &rgb, Object::name("DeviceRGB"), pairs); + page( + &mut doc, + &[("A", real), ("B", referred), ("C", inverted)], + "q 200 0 0 80 6 690 cm /A Do Q q 200 0 0 80 6 590 cm /B Do Q q 200 0 0 80 6 490 cm /C Do Q", + ); + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + optimize(&mut doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap(); + for r in [real, referred] { + let s = stream(&doc, r); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"Decode")), (Some(1), None), "{r:?}"); + } + let s = stream(&doc, inverted); + let decode = Object::Array(vec![1.into(), 0.into()]); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"Decode")), (Some(1), Some(&decode))); +} + +/// With Retain, a Group 3 halftone above 450 ppi, which isn't resampled, stays Group 3 byte for +/// byte: Retain stores nothing again that it doesn't resample. +#[test] +fn retain_keeps_a_group_3_halftone_as_it_is() { + let (w, h) = (64u32, 64u32); + let a: Vec = vec![1; 64]; + let b: Vec = std::iter::once(0).chain(std::iter::repeat_n(1, 64)).collect(); + let runs: Vec<&[u32]> = (0..h).map(|y| if y % 2 == 0 { &a[..] } else { &b[..] }).collect(); + let g3 = group_3(&runs, false, None); + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(0)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone()))); + // 7.68 pt for 64 samples is 600 ppi. + page(&mut doc, &[("A", fax)], "q 7.68 0 0 7.68 100 600 cm /A Do Q"); + assert_eq!(doc.decoded_image(&stream(&doc, fax)).unwrap().len(), 64 * 8, "the fixture decodes"); + let r = optimize(&mut doc, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(stream(&doc, fax).raw[..], g3[..]); +} + +/// Black-and-white RGB stays RGB where grey would be drawn otherwise: drawn, on any of its uses, +/// where `/DefaultGray` or `/DefaultRGB` is in force (ISO 32000-2 §8.6.5.6), from a page's +/// resources or a form's own, as grey would then go through DefaultGray, or no longer through +/// DefaultRGB; or with a soft mask whose `/Matte` has three components, one for each of RGB's. A +/// form drawn where they are in force counts too, though it has resources of its own. The same +/// image drawn plainly, or with a soft mask and no `/Matte`, becomes grey. +#[test] +fn black_and_white_rgb_stays_rgb_where_grey_would_be_drawn_otherwise() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let rgb_data = samples_of(&bilevel(w, h, false), w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let image = |doc: &mut Document, cs: &str, data: &[u8], entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name(cs)); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let opaque = vec![255u8; (w * h) as usize]; + let plain_mask = image(&mut doc, "DeviceGray", &opaque, &[]); + let matte_mask = image(&mut doc, "DeviceGray", &opaque, &[("Matte", Object::Array(vec![0.into(); 3]))]); + let plain = image(&mut doc, "DeviceRGB", &rgb_data, &[]); + let masked = image(&mut doc, "DeviceRGB", &rgb_data, &[("SMask", Object::Ref(plain_mask))]); + let matted = image(&mut doc, "DeviceRGB", &rgb_data, &[("SMask", Object::Ref(matte_mask))]); + let [under_gray, under_rgb, shared, in_form, nested] = [(); 5].map(|_| image(&mut doc, "DeviceRGB", &rgb_data, &[])); + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let spaces = |key: &str| { + let family = if key == "DefaultGray" { "CalGray" } else { "CalRGB" }; + let mut c = Dict::new(); + c.set(key.as_bytes().to_vec(), Object::Array(vec![Object::name(family), Object::Dict(white_point.clone())])); + Object::Dict(c) + }; + let draw = |names: &[&str]| names.iter().enumerate().map(|(i, n)| format!("q 200 0 0 80 6 {} cm /{n} Do Q ", 690 - 100 * i)).collect::(); + let form = |doc: &mut Document, im: ObjRef, spaces: Option| { + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(im)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + if let Some(spaces) = spaces { + resources.set(b"ColorSpace".to_vec(), spaces); + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("XObject")); + f.set(b"Subtype".to_vec(), Object::name("Form")); + f.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + f.set(b"Resources".to_vec(), Object::Dict(resources)); + doc.add(Object::Stream(Stream::from_raw(f, draw(&["Im"]).into_bytes()))) + }; + // Drawn plainly first, then where DefaultGray is in force: the second walk must happen. + let plain_form = form(&mut doc, nested, None); + page(&mut doc, &[("A", plain), ("B", masked), ("C", matted), ("D", shared), ("E", plain_form)], &draw(&["A", "B", "C", "D", "E"])); + for (key, xobjects) in [("DefaultGray", vec![("A", under_gray), ("B", shared), ("C", plain_form)]), ("DefaultRGB", vec![("A", under_rgb)])] { + let names: Vec<&str> = xobjects.iter().map(|(n, _)| *n).collect(); + let p = page(&mut doc, &xobjects, &draw(&names)); + doc.update_dict(p, |d| { + let mut res = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap(); + res.set(b"ColorSpace".to_vec(), spaces(key)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + } + // A form with its own resources, DefaultGray among them, on a page without any. + let gray_form = form(&mut doc, in_form, Some(spaces("DefaultGray"))); + page(&mut doc, &[("F", gray_form)], "/F Do"); + let kept = [under_gray, under_rgb, shared, in_form, nested, matted]; + let before = kept.map(|r| stream(&doc, r)); + // Colour and grey settings that leave images alone, so only this conversion acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let settings = Settings { color: keep, gray: keep, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 2), "{r:?}"); + for i in [plain, masked] { + let s = stream(&doc, i); + assert_eq!((s.dict.get(b"ColorSpace"), s.dict.int(b"BitsPerComponent")), (Some(&Object::name("DeviceGray")), Some(1)), "{i:?}"); + } + for (i, b) in kept.iter().zip(&before) { + let s = stream(&doc, *i); + assert_eq!((&s.dict, &s.raw), (&b.dict, &b.raw), "{i:?}"); + } + + // A use that couldn't be followed (forms nested too deep) may be where DefaultGray is in force. + let mut doc = Document::new_empty(); + let img = image(&mut doc, "DeviceRGB", &rgb_data, &[]); + let deep = nested_forms(&mut doc, img, 13); + page(&mut doc, &[("Im", img), ("F", deep)], &draw(&["Im", "F"])); + let before = stream(&doc, img); + optimize(&mut doc, &settings).unwrap(); + assert_eq!(stream(&doc, img).raw, before.raw, "a use not followed"); +} + +/// An empty document, and a maker of images of only black and white as eight-bit `DeviceRGB`. +fn rgb_page_fixture() -> (Document, impl Fn(&mut Document) -> ObjRef) { + let (w, h) = (1200u32, 480u32); + let rgb_data = samples_of(&bilevel(w, h, false), w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let image = move |doc: &mut Document| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + doc.add(Object::Stream(Stream::flate(d, &rgb_data))) + }; + (Document::new_empty(), image) +} + +/// `q … cm /N Do Q` for each name, one under the other. +fn draw_each(names: &[&str]) -> String { + names.iter().enumerate().map(|(i, n)| format!("q 200 0 0 80 6 {} cm /{n} Do Q ", 690 - 100 * i)).collect() +} + +/// An `/XObject` dictionary of `names`. +fn xobject_dict(names: &[(&str, ObjRef)]) -> Dict { + let mut x = Dict::new(); + for (n, r) in names { + x.set(n.as_bytes().to_vec(), Object::Ref(*r)); + } + x +} + +/// A stream drawing `/Im` (or nothing) whose `/Resources` is `resources`, with `extra` in its +/// dictionary: a form, an appearance or a tiling pattern. +fn drawing(doc: &mut Document, resources: Option, extra: &[(&str, Object)]) -> ObjRef { + let mut f = Dict::new(); + f.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + if let Some(resources) = resources { + f.set(b"Resources".to_vec(), resources); + } + for (k, v) in extra { + f.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::from_raw(f, draw_each(&["Im"]).into_bytes()))) +} + +fn form_entries() -> [(&'static str, Object); 2] { + [("Type", Object::name("XObject")), ("Subtype", Object::name("Form"))] +} + +/// Resources of `entries`. +fn resources_of(entries: Vec<(&str, Object)>) -> Dict { + let mut res = Dict::new(); + for (k, v) in entries { + res.set(k.as_bytes().to_vec(), v); + } + res +} + +/// Give page `p` an annotation whose normal appearance is `appearance`. +fn annotate(doc: &mut Document, p: ObjRef, appearance: ObjRef) { + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(appearance)); + let mut annotation = Dict::new(); + annotation.set(b"Subtype".to_vec(), Object::name("Square")); + annotation.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 100.into(), 100.into()])); + annotation.set(b"AP".to_vec(), Object::Dict(ap)); + let annotation = doc.add(Object::Dict(annotation)); + let mut annots = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").and_then(|a| a.as_array().cloned())).unwrap_or_default(); + annots.push(Object::Ref(annotation)); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); +} + +/// `/DefaultGray` as a calibrated grey, in a `/ColorSpace` resource dictionary. +fn default_gray() -> Object { + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let mut c = Dict::new(); + c.set(b"DefaultGray".to_vec(), Object::Array(vec![Object::name("CalGray"), Object::Dict(white_point)])); + Object::Dict(c) +} + +/// Optimize with colour and grey settings that leave images alone, so only packing acts; the +/// report's images resampled and stored again. +fn pack_only(doc: &mut Document) -> (usize, usize) { + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let r = optimize(doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap(); + (r.images_resampled, r.images_recompressed) +} + +/// Black-and-white RGB becomes grey for every use at once, so it stays RGB, byte for byte, when +/// any use may be beyond the page content and forms the optimizer reads, where other default +/// colour spaces may be in force: drawn by an annotation's appearance whose resources make +/// `/DefaultGray` the grey it would be drawn in; by a tiling pattern; through an `/XObject` +/// dictionary, or a form, that a page shares with such an appearance; +/// referred to with another generation by the page than by such an appearance; drawn plainly on +/// one page and, named with another generation, on a page whose resources set `/DefaultGray`; +/// drawn plainly and only listed (not drawn) where `/DefaultGray` is in force, under the same +/// generation or another, as a Type3 glyph without resources could draw it there; or held in an +/// array elsewhere (another image's `/Alternates`). +#[test] +fn black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb() { + let (mut doc, image) = rgb_page_fixture(); + let [in_annotation, in_pattern, shared_xobjects, shared_form, other_generation, generation_under_defaults, listed, listed_as_1, in_array] = + [(); 9].map(|_| image(&mut doc)); + let gray_resources = |im: Object| Some(Object::Dict(resources_of(vec![("XObject", im), ("ColorSpace", default_gray())]))); + let direct = |im: ObjRef| Object::Dict(xobject_dict(&[("Im", im)])); + + let appearance = drawing(&mut doc, gray_resources(direct(in_annotation)), &form_entries()); + let pattern_entries = + [("PatternType", 1.into()), ("PaintType", 1.into()), ("TilingType", 1.into()), ("XStep", 612.into()), ("YStep", 792.into())]; + let pattern = drawing(&mut doc, Some(Object::Dict(resources_of(vec![("XObject", direct(in_pattern))]))), &pattern_entries); + let p = page(&mut doc, &[("A", in_annotation), ("B", in_pattern)], &(draw_each(&["A", "B"]) + "/Pattern cs /P scn 0 0 10 10 re f")); + annotate(&mut doc, p, appearance); + doc.update_dict(p, |d| { + let mut res = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap(); + let mut patterns = Dict::new(); + patterns.set(b"P".to_vec(), Object::Ref(pattern)); + res.set(b"Pattern".to_vec(), Object::Dict(patterns)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + + // An /XObject dictionary of its own, read for a page, and a form drawn by a page, each also + // used by an appearance under DefaultGray. + let x = doc.add(Object::Dict(xobject_dict(&[("Im", shared_xobjects)]))); + let q = page(&mut doc, &[], &draw_each(&["Im"])); + doc.update_dict(q, |d| d.set(b"Resources".to_vec(), Object::Dict(resources_of(vec![("XObject", Object::Ref(x))])))).unwrap(); + let appearance = drawing(&mut doc, gray_resources(Object::Ref(x)), &form_entries()); + annotate(&mut doc, q, appearance); + let form = drawing(&mut doc, Some(Object::Dict(resources_of(vec![("XObject", direct(shared_form))]))), &form_entries()); + let q = page(&mut doc, &[("F", form)], &draw_each(&["F"])); + let appearance = drawing(&mut doc, gray_resources(Object::Dict(xobject_dict(&[("Im", form)]))), &form_entries()); + annotate(&mut doc, q, appearance); + + // The page names it with generation 1 (it is 0); the appearance with 0. + let q = page(&mut doc, &[("Im", ObjRef { generation: 1, ..other_generation })], &draw_each(&["Im"])); + let appearance = drawing(&mut doc, gray_resources(direct(other_generation)), &form_entries()); + annotate(&mut doc, q, appearance); + + // Drawn plainly as 0, and as 1 where DefaultGray is in force. + page(&mut doc, &[("Im", generation_under_defaults)], &draw_each(&["Im"])); + let q = page(&mut doc, &[], &draw_each(&["Im"])); + let gray = resources_of(vec![ + ("XObject", Object::Dict(xobject_dict(&[("Im", ObjRef { generation: 1, ..generation_under_defaults })]))), + ("ColorSpace", default_gray()), + ]); + doc.update_dict(q, |d| d.set(b"Resources".to_vec(), Object::Dict(gray))).unwrap(); + + // Drawn plainly, and only listed where DefaultGray is in force, as 0 and as 1. + page(&mut doc, &[("A", listed), ("B", listed_as_1)], &draw_each(&["A", "B"])); + let q = page(&mut doc, &[], ""); + let gray = resources_of(vec![ + ("XObject", Object::Dict(xobject_dict(&[("A", listed), ("B", ObjRef { generation: 1, ..listed_as_1 })]))), + ("ColorSpace", default_gray()), + ]); + doc.update_dict(q, |d| d.set(b"Resources".to_vec(), Object::Dict(gray))).unwrap(); + + // Another image drawn on the page lists it as an alternate. + let mut alternate = Dict::new(); + alternate.set(b"Image".to_vec(), Object::Ref(in_array)); + let holder = image(&mut doc); + doc.update_dict(holder, |d| { + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Alternates".to_vec(), Object::Array(vec![Object::Dict(alternate)])); + }) + .unwrap(); + page(&mut doc, &[("Im", in_array), ("Alt", holder)], &draw_each(&["Im"])); + + let kept = [in_annotation, in_pattern, shared_xobjects, shared_form, other_generation, generation_under_defaults, listed, listed_as_1, in_array]; + let before = kept.map(|r| stream(&doc, r)); + pack_only(&mut doc); + for (i, b) in kept.iter().zip(&before) { + let s = stream(&doc, *i); + assert_eq!((&s.dict, &s.raw), (&b.dict, &b.raw), "{i:?}"); + } +} + +/// Black-and-white RGB that the page content and forms the optimizer reads are the only uses of +/// becomes grey, however its resources are written: drawn on two pages, by a form, under two +/// names on one page; through resources that are an object of their own, shared by two pages, +/// whose `/XObject` dictionary is one too; through resources an object of their own with a direct +/// `/XObject` dictionary; through resources a page inherits from the page tree; by a form whose +/// `/Resources` is a reference; by a form without resources, through its page's; listed under a +/// second name nothing draws; through resources an object of their own that a page without +/// content lists too; and with an object nothing in the document reaches referring to it as well. +#[test] +fn black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey() { + let (mut doc, image) = rgb_page_fixture(); + let [everywhere, shared_resources, resources_object, inherited, form_resources_object, no_resources, spare_name, no_contents, unreachable] = + [(); 9].map(|_| image(&mut doc)); + let set_resources = |doc: &mut Document, q: ObjRef, res: Object| doc.update_dict(q, |d| d.set(b"Resources".to_vec(), res)).unwrap(); + + let form = + drawing(&mut doc, Some(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", everywhere)])))]))), &form_entries()); + page(&mut doc, &[("E", everywhere), ("F", form)], &draw_each(&["E", "F"])); + page(&mut doc, &[("E", everywhere), ("G", everywhere)], &draw_each(&["E", "G"])); + + let x = doc.add(Object::Dict(xobject_dict(&[("Im", shared_resources)]))); + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Ref(x))]))); + for _ in 0..2 { + let q = page(&mut doc, &[], &draw_each(&["Im"])); + set_resources(&mut doc, q, Object::Ref(res)); + } + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", resources_object)])))]))); + let q = page(&mut doc, &[], &draw_each(&["Im"])); + set_resources(&mut doc, q, Object::Ref(res)); + + let q = page(&mut doc, &[], &draw_each(&["I"])); + doc.update_dict(q, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + set_resources(&mut doc, pages, Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("I", inherited)])))]))); + + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", form_resources_object)])))]))); + let form = drawing(&mut doc, Some(Object::Ref(res)), &form_entries()); + page(&mut doc, &[("F", form)], &draw_each(&["F"])); + let form = drawing(&mut doc, None, &form_entries()); + page(&mut doc, &[("F", form), ("Im", no_resources)], &draw_each(&["F"])); + + page(&mut doc, &[("C", spare_name), ("D", spare_name)], &draw_each(&["C"])); + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", no_contents)])))]))); + for drawn in [true, false] { + let q = page(&mut doc, &[], &draw_each(&["Im"])); + set_resources(&mut doc, q, Object::Ref(res)); + if !drawn { + doc.update_dict(q, |d| { + d.remove(b"Contents"); + }) + .unwrap(); + } + } + + page(&mut doc, &[("Im", unreachable)], &draw_each(&["Im"])); + // A form nothing refers to, drawing it under DefaultGray. + let orphan = resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", unreachable)]))), ("ColorSpace", default_gray())]); + drawing(&mut doc, Some(Object::Dict(orphan)), &form_entries()); + + let grey = [everywhere, shared_resources, resources_object, inherited, form_resources_object, no_resources, spare_name, no_contents, unreachable]; + assert_eq!(pack_only(&mut doc), (0, grey.len())); + for i in grey { + let s = stream(&doc, i); + assert_eq!((s.dict.get(b"ColorSpace"), s.dict.int(b"BitsPerComponent")), (Some(&Object::name("DeviceGray")), Some(1)), "{i:?}"); + } +} + +/// LibreOffice writes one resources object for a page and its `/Pages` node, and a transparency +/// group's form whose `/Resources` is that same object, which the object lists in turn. Black- +/// and-white RGB in it becomes grey: every reference is one the page walk reads, the cycle +/// included. Drawn from outside as well, by an appearance under `/DefaultGray` that draws the +/// form, it stays RGB. +#[test] +fn black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey() { + for outside in [false, true] { + let (mut doc, image) = rgb_page_fixture(); + let img = image(&mut doc); + let res = doc.add(Object::Dict(Dict::new())); + let form = drawing(&mut doc, Some(Object::Ref(res)), &form_entries()); + doc.update_dict(res, |d| d.set(b"XObject".to_vec(), Object::Dict(xobject_dict(&[("Im", img), ("Tr", form)])))).unwrap(); + let p = page(&mut doc, &[], &draw_each(&["Im", "Tr"])); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + for holder in [p, pages] { + doc.update_dict(holder, |d| d.set(b"Resources".to_vec(), Object::Ref(res))).unwrap(); + } + if outside { + let gray = resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", form)]))), ("ColorSpace", default_gray())]); + let appearance = drawing(&mut doc, Some(Object::Dict(gray)), &form_entries()); + annotate(&mut doc, p, appearance); + } + let before = stream(&doc, img); + pack_only(&mut doc); + let s = stream(&doc, img); + if outside { + assert_eq!((&s.dict, &s.raw), (&before.dict, &before.raw), "drawn from outside too"); + } else { + assert_eq!((s.dict.get(b"ColorSpace"), s.dict.int(b"BitsPerComponent")), (Some(&Object::name("DeviceGray")), Some(1))); + } + } +} + +/// Black-and-white images above 450 ppi are averaged down to 300 ppi, by default: 1-bit Flate, CCITT +/// Group 4 (alone and behind ASCIIHexDecode), an image mask (its inverting decode array stays), and +/// eight-bit grey and RGB of only black and white (the RGB also the other way round, with a decode +/// array that inverts it, so its ties are 1s), all drawn at 600 ppi, are stored at half the size (as +/// Group 4, or 1-bit Flate when that is smaller, as it is on this clean text), each new pixel the +/// majority of the 2 × 2 it covers (a tie black, or for the mask painted). Left at their size: an image at 400 ppi (stored +/// again as Group 4), a Group 4 one at 400 ppi (not even decoded), one behind ASCIIHexDecode at +/// 400 ppi (stored again as Group 4 alone), and one with a soft mask. The page looks the same at +/// screen size. With `downsample` off nothing is resampled; with Retain, Flate stays Flate, CCITT +/// stays CCITT, and eight-bit grey and RGB are left to the grey and colour settings. +#[test] +fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let g4 = |doc: &mut Document, entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("CCITTFaxDecode")])); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Dict(parms)])); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_g4(&text, w, h).unwrap()))) + }; + // Group 4 behind ASCIIHexDecode, its parameters second in the array. + let behind_hex = |doc: &mut Document| { + let r = g4(doc, &grey); + let s = stream(doc, r); + let mut d = s.dict.clone(); + let parms = d.get(b"DecodeParms").and_then(Object::as_array).and_then(|a| a.first()).cloned().unwrap(); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("CCITTFaxDecode")])); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, parms])); + let hex = s.raw.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes(); + doc.set(r, Object::Stream(Stream::from_raw(d, hex))); + r + }; + let flate = mono_image(&mut doc, w, h, &text, None, &grey); + let fax = g4(&mut doc, &grey); + let hex_fax = behind_hex(&mut doc); + let hex_at_400 = behind_hex(&mut doc); + let decode = Object::Array(vec![1.into(), 0.into()]); + let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let eight = doc.add(Object::Stream(Stream::flate(d.clone(), &eight_bit(&text, w, h)))); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + let rgb = doc.add(Object::Stream(Stream::flate(d.clone(), &samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8)))); + d.set(b"Decode".to_vec(), Object::Array([1, 0, 1, 0, 1, 0].map(Object::Int).to_vec())); + let rgb_inverted = doc.add(Object::Stream(Stream::flate(d, &samples_of(&text, w, h, [&[255, 255, 255], &[0, 0, 0]], 8)))); + let at_400 = mono_image(&mut doc, w, h, &text, None, &grey); + let fax_at_400 = g4(&mut doc, &grey); + let mut sm = Dict::new(); + sm.set(b"Subtype".to_vec(), Object::name("Image")); + sm.set(b"Width".to_vec(), Object::Int(2)); + sm.set(b"Height".to_vec(), Object::Int(1)); + sm.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + sm.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let smask = doc.add(Object::Stream(Stream::flate(sm, &[255, 200]))); + let soft = mono_image(&mut doc, w, h, &text, None, &[grey[0].clone(), grey[1].clone(), ("SMask", Object::Ref(smask))]); + // 144 × 57.6 pt is 2 × 0.8 in: 600 ppi. 216 × 86.4 pt is 400 ppi. + page( + &mut doc, + &[ + ("A", flate), + ("B", fax), + ("C", mask), + ("D", eight), + ("E", at_400), + ("F", fax_at_400), + ("G", soft), + ("H", hex_fax), + ("I", hex_at_400), + ("J", rgb), + ("K", rgb_inverted), + ], + "q 144 0 0 57.6 20 700 cm /A Do Q q 144 0 0 57.6 200 700 cm /B Do Q q 0 0 1 rg 144 0 0 57.6 380 700 cm /C Do Q \ + q 144 0 0 57.6 20 600 cm /D Do Q q 216 0 0 86.4 200 580 cm /E Do Q q 216 0 0 86.4 20 480 cm /F Do Q \ + q 144 0 0 57.6 260 480 cm /G Do Q q 144 0 0 57.6 430 600 cm /H Do Q q 216 0 0 86.4 20 360 cm /I Do Q q 144 0 0 57.6 260 380 cm /J Do Q \ + q 144 0 0 57.6 430 480 cm /K Do Q", + ); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + let size = |doc: &Document, r: ObjRef| { + let d = stream(doc, r).dict; + (d.int(b"Width").unwrap(), d.int(b"Height").unwrap()) + }; + // Each new pixel: 1 when more than two of the 2 × 2 it covers are, or two when 1 is the dark + // sample (the mask's decode array inverts it, so its painted part is 1). + let stride = (w as usize).div_ceil(8); + let bit = |x: usize, y: usize| text[y * stride + x / 8] & (0x80 >> (x % 8)) != 0; + let halve = |dark_one: bool| { + let mut out = vec![0u8; (w as usize / 2).div_ceil(8) * h as usize / 2]; + for y in 0..h as usize / 2 { + for x in 0..w as usize / 2 { + let ones = [(0, 0), (1, 0), (0, 1), (1, 1)].iter().filter(|(dx, dy)| bit(2 * x + dx, 2 * y + dy)).count(); + if ones > 2 || (dark_one && ones == 2) { + out[y * (w as usize / 2).div_ceil(8) + x / 8] |= 0x80 >> (x % 8); + } + } + } + out + }; + let (halved, halved_mask) = (halve(false), halve(true)); + assert_ne!(halved, halved_mask, "the picture has ties"); + + // The grey and colour settings left alone too: an image of more bits would otherwise take + // them when smaller. + let mut off = doc.clone(); + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let mono = MonoSettings { downsample: false, ..MonoSettings::REDUCE }; + let r = optimize(&mut off, &Settings { mono, gray: keep, color: keep, ..Settings::default() }).unwrap(); + assert_eq!(r.images_resampled, 0, "{r:?}"); + assert!([flate, fax, mask, eight, hex_fax, rgb, rgb_inverted].iter().all(|i| size(&off, *i) == (1200, 480))); + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + // Not the inverted RGB: the colour settings leave an image with a decode array alone. + assert_eq!(r.images_resampled, 6, "{r:?}"); + assert_eq!(stream(&kept, flate).dict.name(b"Filter"), Some(&b"FlateDecode"[..]), "Retain: Flate stays Flate"); + for i in [fax, hex_fax] { + assert_eq!(stream(&kept, i).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "and CCITT stays CCITT: {i:?}"); + } + assert_eq!(raw(&kept, hex_at_400), raw(&doc, hex_at_400), "and nothing else changes"); + for i in [flate, fax, hex_fax] { + assert_eq!((size(&kept, i), kept.decoded_image(&stream(&kept, i)).unwrap()), ((600, 240), halved.clone()), "{i:?}"); + } + let bits = [eight, rgb, rgb_inverted].map(|i| stream(&kept, i).dict.int(b"BitsPerComponent")); + assert_eq!(bits, [Some(8); 3], "Retain leaves eight bits to the grey and colour settings"); + + let untouched = [fax_at_400].map(|r| raw(&doc, r)); + let hex_len = raw(&doc, hex_at_400).len(); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (7, 3), "{r:?}"); + assert_eq!(stream(&doc, rgb).dict.get(b"ColorSpace"), Some(&Object::name("DeviceGray"))); + for i in [flate, fax, mask, eight, hex_fax, rgb] { + let s = stream(&doc, i); + assert_eq!((size(&doc, i), s.dict.int(b"BitsPerComponent")), ((600, 240), if i == mask { None } else { Some(1) })); + assert!(matches!(s.dict.name(b"Filter"), Some(b"CCITTFaxDecode" | b"FlateDecode")), "{i:?}"); + assert_eq!(doc.decoded_image(&s).unwrap(), if i == mask { halved_mask.clone() } else { halved.clone() }, "{i:?}"); + } + assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + // Grey now, with grey's decode pair, which makes 1 black: its ties are black too. + let s = stream(&doc, rgb_inverted); + let inverting = Object::Array(vec![1.into(), 0.into()]); + assert_eq!( + (size(&doc, rgb_inverted), s.dict.get(b"ColorSpace"), s.dict.get(b"Decode")), + ((600, 240), Some(&Object::name("DeviceGray")), Some(&inverting)) + ); + assert_eq!(doc.decoded_image(&s).unwrap(), halved.iter().map(|b| !b).collect::>(), "the same picture, ties black"); + for i in [at_400, soft, hex_at_400] { + assert_eq!((size(&doc, i), stream(&doc, i).dict.name(b"Filter")), ((1200, 480), Some(&b"CCITTFaxDecode"[..])), "{i:?}"); + } + assert!(raw(&doc, hex_at_400).len() < hex_len, "the ASCIIHexDecode is dropped"); + assert_eq!(doc.decoded_image(&stream(&doc, hex_at_400)).unwrap(), text, "and the picture kept"); + assert_eq!([fax_at_400].map(|r| raw(&doc, r)), untouched); + let after = render(&doc); + let differ = before.iter().zip(&after).filter(|(a, b)| a.abs_diff(**b) > 64).count(); + assert!(differ * 200 < before.len(), "{differ} of {} channel values differ visibly", before.len()); +} + +/// Monochrome downsampling leaves a halftone or a dither at its resolution, as most new pixels +/// would cover both black and white and taking the sample covering more would turn its tones +/// solid: a 50% checkerboard, a 25% ordered dither and an error-diffused one, each at 600 ppi. +/// Text at 600 ppi, which covers both only along its edges, is averaged down. Rows no wider than +/// a fax row (`MAX_CCITT_COLUMNS`) are averaged down too, but not one column wider: the averaging +/// keeps a counter for each column. +#[test] +fn monochrome_downsampling_leaves_halftones_and_overwide_rows_at_their_resolution() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let pattern = |white: &dyn Fn(usize, usize) -> bool| { + let mut data = vec![0u8; stride * h as usize]; + for y in 0..h as usize { + for x in 0..w as usize { + if white(x, y) { + data[y * stride + x / 8] |= 0x80 >> (x % 8); + } + } + } + data + }; + let diffused = dithered(w, h); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let mut doc = Document::new_empty(); + let text = mono_image(&mut doc, w, h, &bilevel(w, h, false), None, &grey); + let halftones = + [pattern(&|x, y| (x + y) % 2 == 0), pattern(&|x, y| x % 2 == 1 || y % 2 == 1), pattern(&|x, y| diffused[y * w as usize + x] == 255)] + .map(|data| mono_image(&mut doc, w, h, &data, None, &grey)); + // 144 × 57.6 pt is 600 ppi. + let names = ["T", "A", "B", "C"]; + let content: String = (0..4).map(|i| format!("q 144 0 0 57.6 {} 600 cm /{} Do Q ", 6 + 150 * i, names[i])).collect(); + page(&mut doc, &names.into_iter().zip([text, halftones[0], halftones[1], halftones[2]]).collect::>(), &content); + optimize(&mut doc, &Settings::default()).unwrap(); + let size = |r: ObjRef| { + let d = stream(&doc, r).dict; + (d.int(b"Width"), d.int(b"Height")) + }; + assert_eq!(size(text), (Some(600), Some(240)), "text"); + for (i, r) in halftones.iter().enumerate() { + assert_eq!(size(*r), (Some(1200), Some(480)), "halftone {i}"); + } + + for (columns, resampled) in [(pdfcraft_filters::MAX_CCITT_COLUMNS, 1), (pdfcraft_filters::MAX_CCITT_COLUMNS + 1, 0)] { + let mut doc = Document::new_empty(); + let white = vec![0xFF; (columns as usize).div_ceil(8) * 2]; + let img = mono_image(&mut doc, columns, 2, &white, None, &grey); + // Far above 450 ppi both ways. + page(&mut doc, &[("A", img)], "q 612 0 0 0.1 0 400 cm /A Do Q"); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_resampled, resampled, "{columns} columns: {r:?}"); + } +} + +/// A fax image behind a decompression bomb is left alone: the filters before its decoder may +/// produce no more than a fax code of its size can take (about a byte a pixel), not the 1 GiB +/// limit. Here a Group 4 page at 600 ppi behind Flate, with four megabytes of zeros after its +/// code; without them it is averaged down. +#[test] +fn a_fax_image_behind_a_decompression_bomb_is_left_alone() { + let (w, h) = (1200u32, 480u32); + let g4 = pdfcraft_filters::encode_g4(&bilevel(w, h, false), w, h).unwrap(); + for padding in [0, 4 << 20] { + let mut doc = Document::new_empty(); + let mut code = g4.clone(); + code.resize(g4.len() + padding, 0); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")])); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)])); + let img = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&code)))); + // 144 × 57.6 pt is 600 ppi. + page(&mut doc, &[("A", img)], "q 144 0 0 57.6 20 600 cm /A Do Q"); + let before = stream(&doc, img).raw; + let r = optimize(&mut doc, &Settings::default()).unwrap(); + if padding == 0 { + assert_eq!(r.images_resampled, 1, "{r:?}"); + } else { + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(stream(&doc, img).raw, before); + } + } +} + +/// A CCITT image `w` × `h` coded as Group 4 from `bits`, behind ASCIIHexDecode when `hex`. +fn g4_image(doc: &mut Document, bits: &[u8], (w, h): (u32, u32), hex: bool) -> ObjRef { + let g4 = pdfcraft_filters::encode_g4(bits, w, h).unwrap(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + let data = if hex { + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("CCITTFaxDecode")])); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)])); + g4.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect() + } else { + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + g4 + }; + doc.add(Object::Stream(Stream::from_raw(d, data))) +} + +/// What Monochrome Images leaves alone and what it may still do. A Group 4 halftone above 450 ppi +/// isn't resampled, so it stays byte for byte: alone, by default; behind ASCIIHexDecode, with +/// Retain (by default it is stored again as Group 4 alone). Under Retain a resampled Flate scan +/// stays Flate though Group 4 would be smaller. A busy Group 4 image behind ASCIIHexDecode, whose +/// code is more than three times its raster, decodes within a fax code's bound and is stored +/// again as Group 4 alone. Sixteen-bit grey of only 0x0000 and 0xFFFF isn't eight-bit black and +/// white, and keeps its bytes. +#[test] +fn monochrome_images_keep_what_they_should() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let checker: Vec = (0..h as usize).flat_map(|y| vec![if y % 2 == 0 { 0xAA } else { 0x55 }; stride]).collect(); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + // Twelve dark bands a row, their edges ragged by up to five samples, as a scan's are. + let mut ragged = vec![0xFFu8; stride * h as usize]; + for y in 0..h as usize { + for band in 0..12 { + let (x0, x1) = (band * 100 + (next() % 5) as usize, band * 100 + 50 + (next() % 5) as usize); + for x in x0..x1.min(w as usize) { + ragged[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let busy: Vec = (0..256).flat_map(|y| [if y % 2 == 0 { 0xAA } else { 0xFF }; 32]).collect(); + let build = || { + let mut doc = Document::new_empty(); + let alone = g4_image(&mut doc, &checker, (w, h), false); + let wrapped = g4_image(&mut doc, &checker, (w, h), true); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let scan = mono_image(&mut doc, w, h, &ragged, None, &grey); + let fax = g4_image(&mut doc, &busy, (256, 256), true); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(16)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let samples: Vec = eight_bit(&bilevel(w, h, false), w, h).iter().flat_map(|v| [*v, *v]).collect(); + let deep = doc.add(Object::Stream(Stream::flate(d, &samples))); + // 144 × 57.6 pt is 600 ppi; 46.08 pt for 256 samples is 400 ppi. + page( + &mut doc, + &[("A", alone), ("B", wrapped), ("C", scan), ("D", fax), ("E", deep)], + "q 144 0 0 57.6 20 700 cm /A Do Q q 144 0 0 57.6 200 700 cm /B Do Q q 144 0 0 57.6 20 600 cm /C Do Q \ + q 46.08 0 0 46.08 200 600 cm /D Do Q q 144 0 0 57.6 20 500 cm /E Do Q", + ); + (doc, [alone, wrapped, scan, fax, deep]) + }; + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + let filters = |doc: &Document, r: ObjRef| stream(doc, r).filters().len(); + + let (mut doc, [alone, wrapped, scan, fax, deep]) = build(); + let before = [alone, deep].map(|r| raw(&doc, r)); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!([alone, deep].map(|r| raw(&doc, r)), before, "a Group 4 halftone alone, and sixteen-bit grey"); + assert_eq!(filters(&doc, wrapped), 1, "a wrapped Group 4 halftone is stored again alone by default"); + assert_eq!(stream(&doc, scan).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "the scan: Group 4 is smaller"); + assert_eq!((stream(&doc, fax).dict.name(b"Filter"), filters(&doc, fax)), (Some(&b"CCITTFaxDecode"[..]), 1), "the busy fax image"); + + let (mut doc, [_, wrapped, scan, _, _]) = build(); + let before = raw(&doc, wrapped); + let r = optimize(&mut doc, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!(raw(&doc, wrapped), before, "Retain: a wrapped Group 4 halftone stays as it is"); + let s = stream(&doc, scan); + assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(600), Some(&b"FlateDecode"[..])), "Retain: Flate stays Flate: {r:?}"); +} + +/// `rows` coded as CCITT Group 3, one-dimensional (`/K 0`): each row its runs in turn, white first, +/// with ITU-T T.4's terminating codes for the few lengths used here. With `on_bytes`, each row +/// starts on a byte: `Some(false)`, with no end-of-line codes (TIFF's Modified Huffman); `Some(true)`, +/// after an end-of-line code that the fill ends on a byte (T.4's fill, as TIFF writes it). +fn group_3(rows: &[&[u32]], mixed: bool, on_bytes: Option) -> Vec { + let code = |white: bool, run: u32| match (white, run) { + (true, 0) => "00110101", + (true, 1) => "000111", + (false, 1) => "010", + (true, 8) => "10011", + (true, 16) => "101010", + (true, 32) => "00011011", + (false, 8) => "000101", + (false, 16) => "0000010111", + (false, 32) => "000001101010", + _ => panic!("no code here for a run of {run}"), + }; + let mut bits = String::new(); + for row in rows { + let codes = mixed || on_bytes == Some(true); + if on_bytes.is_some() { + // The end-of-line code is 12 bits. + while !(bits.len() + if codes { 12 } else { 0 }).is_multiple_of(8) { + bits.push('0'); + } + } + if codes { + bits.push_str("000000000001"); + } + // Mixed coding (/K above 0): a 1 bit after the code, the row one-dimensional. + if mixed { + bits.push('1'); + } + row.iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run))); + } + bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect() +} + +/// A Group 3 fax image is stored again as Group 4 when that is smaller, the same picture (to the +/// pixel on the page, against the picture in Flate); with Retain it stays Group 3. One-dimensional (/K 0) and mixed (/K 1, with +/// end-of-line codes) alike, and rows on bytes, with no codes and with codes the fill ends on a byte +/// (without `/EndOfLine`, which reading by the flag misread). +#[test] +fn group_3_fax_images_become_group_4() { + let (w, h) = (64u32, 64u32); + let runs: Vec<&[u32]> = [&[32, 32][..], &[16; 4], &[8; 8], &[0, 16, 16, 32]].into_iter().flat_map(|r| std::iter::repeat_n(r, 16)).collect(); + // White is 1, as CCITTFaxDecode gives it without /BlackIs1. + let mut bits = Vec::new(); + for row in &runs { + let mut packed = [0u8; 8]; + let mut x = 0; + for (i, run) in row.iter().enumerate() { + for p in x..x + *run as usize { + if i % 2 == 0 { + packed[p / 8] |= 0x80 >> (p % 8); + } + } + x += *run as usize; + } + bits.extend(packed); + } + for (k, on_bytes) in [(0, None), (1, None), (0, Some(false)), (0, Some(true)), (1, Some(true))] { + let g3 = group_3(&runs, k > 0, on_bytes); + let (end_of_line, encoded_byte_align) = (k > 0 && on_bytes.is_none(), on_bytes.is_some()); + let p = pdfcraft_filters::CcittParams { k, columns: w.into(), end_of_line, encoded_byte_align, ..Default::default() }; + assert_eq!(pdfcraft_filters::decode_ccitt(&g3, &p, h).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the Group 3 data is the picture"); + + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + // The page with the picture in Flate, as it should look. + let mut control = Document::new_empty(); + let flate = control.add(Object::Stream(Stream::flate(d.clone(), &bits))); + page(&mut control, &[("A", flate)], "q 64 0 0 64 100 600 cm /A Do Q"); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(k)); + parms.set(b"EndOfLine".to_vec(), Object::Bool(end_of_line)); + parms.set(b"EncodedByteAlign".to_vec(), Object::Bool(encoded_byte_align)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + parms.set(b"Rows".to_vec(), Object::Int(h.into())); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone()))); + page(&mut doc, &[("A", fax)], "q 64 0 0 64 100 600 cm /A Do Q"); + + let mut kept = doc.clone(); + optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!(stream(&kept, fax).raw[..], g3[..], "/K {k}, on bytes {on_bytes:?}: Retain keeps Group 3"); + + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 1), "/K {k}, on bytes {on_bytes:?}: {r:?}"); + let s = stream(&doc, fax); + assert_eq!(doc.fax_params(&s).map(|p| p.k), Some(-1), "/K {k}, on bytes {on_bytes:?}: Group 4"); + assert!(s.raw.len() < g3.len(), "{} bytes, from {}", s.raw.len(), g3.len()); + assert_eq!(doc.decoded_image(&s).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the same picture"); + assert!(render(&doc) == render(&control), "/K {k}, on bytes {on_bytes:?}: the page looks as the picture does"); + } +} + #[test] fn settings_choose_what_happens() { let mut doc = Document::new_empty(); diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index d8db2f7ac4..5a2a319fb4 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -231,14 +231,15 @@ impl Pruner<'_> { } } -/// The page's content, concatenated (`/Contents` may be one stream or an array), decoded -/// against one page's [`DecodeBudget`]. An error when a stream can't be decoded or the budget -/// runs out: the page may draw names that can't be seen. +/// The page's content, concatenated (`/Contents` may be one stream or an array) with a space +/// between pieces as the renderer joins them, decoded against one page's [`DecodeBudget`]. An +/// error when a stream can't be decoded or the budget runs out: the page may draw names that +/// can't be seen. pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result, CosError> { let mut out = Vec::new(); for data in DecodeBudget::page().decode_contents(doc, contents) { out.extend(data?); - out.push(b'\n'); + out.push(b' '); } Ok(out) } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 64391bb82d..33dc262fce 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -3767,3 +3767,49 @@ fn bookmark_style_and_colour_are_set_as_the_table_says() { let got: Vec<_> = bookmarks(&saved).into_iter().map(|b| (b.color, b.bold, b.italic)).collect(); assert_eq!(got, [(Some([0.0, 0.0, 1.0]), true, true), (None, false, false)]); } + +/// #3502: a `/Contents` array's pieces are joined with a space, as the renderer joins them, so a +/// `%` comment at the end of a piece without a line break continues through the first line of the +/// next piece in both resource pruning (`extract_pages`) and `page_as_form`. +#[test] +fn contents_pieces_join_with_a_space_across_a_trailing_comment() { + let build_doc = |first_piece: &str| { + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] /Resources 6 0 R >>".into(), // 3 + raw(first_piece), // 4 + raw("/ImHidden Do\n/ImShown Do"), // 5 + "<< /XObject << /ImHidden 7 0 R /ImShown 8 0 R >> >>".into(), // 6 + image(1, 1, 1), // 7 + image(1, 1, 1), // 8 + ]; + open(build(&b, "/Root 1 0 R")) + }; + + // Without a trailing newline in piece 1, `% comment` swallows `/ImHidden Do` on the first + // line of piece 2; only `/ImShown` is drawn. + let commented = build_doc("q Q % comment without newline"); + let extracted = full_roundtrip(&extract_pages(&commented, &[0]).unwrap()); + assert_eq!(xobject_names(&extracted, 0), [b"ImShown".to_vec()], "commented-out Do across piece boundary is pruned"); + + let mut form_dst = Document::new_empty(); + let (form_ref, _) = crate::page_as_form(&mut form_dst, &commented, 0).unwrap(); + let Object::Stream(form_stream) = &*form_dst.get(form_ref) else { panic!("form stream") }; + let form_bytes = form_stream.decoded().unwrap(); + let drawn: Vec> = pdfcraft_content::parse(&form_bytes) + .ops + .into_iter() + .filter(|op| op.op == b"Do") + .filter_map(|op| op.operands.first().and_then(Object::as_name).map(|n| n.to_vec())) + .collect(); + assert_eq!(drawn, [b"ImShown".to_vec()], "page_as_form keeps the first line of piece 2 inside the comment"); + + // Control: when piece 1 ends the comment with a newline, `/ImHidden Do` on piece 2's first + // line is a real operator and stays in both the extracted page and the form XObject. + let newline_ended = build_doc("q Q % comment with newline\n"); + let extracted_ctrl = full_roundtrip(&extract_pages(&newline_ended, &[0]).unwrap()); + let mut ctrl_names = xobject_names(&extracted_ctrl, 0); + ctrl_names.sort(); + assert_eq!(ctrl_names, [b"ImHidden".to_vec(), b"ImShown".to_vec()]); +} diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index aa9a501f66..348ae151ba 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -16,8 +16,9 @@ fn n(v: f64) -> String { if s == "-0" { "0".into() } else { s.into() } } -/// The page's content streams decoded against `budget` and joined; streams that don't decode -/// are left out. `None` when the budget ran out, so part of the content is missing. +/// The page's content streams decoded against `budget` and joined with a space as the renderer +/// joins them; streams that don't decode are left out. `None` when the budget ran out, so part of +/// the content is missing. pub(crate) fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; let mut out = Vec::new(); @@ -25,7 +26,7 @@ pub(crate) fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeB match data { Ok(data) => { out.extend_from_slice(&data); - out.push(b'\n'); + out.push(b' '); } Err(_) if budget.remaining() == 0 => return None, Err(_) => {} diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index e6432d8aec..885756e8c6 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1062,3 +1062,85 @@ fn a_booklet_prints_both_sides_on_the_short_edge() { assert_eq!(booklet_duplex(BookletSubset::FrontOnly), spool::Duplex::Off); assert_eq!(booklet_duplex(BookletSubset::BackOnly), spool::Duplex::Off); } + +/// #3502: `/Contents` array pieces are joined with a space, matching the renderer, so a `%` +/// comment at the end of a piece without a line break continues through the first line of the +/// next piece in both `impose` and `text_reads_up`. +#[test] +fn contents_pieces_join_with_a_space_across_a_trailing_comment() { + let build_doc = |first_piece: &[u8]| { + let mut doc = Document::new_empty(); + let font = doc.add( + [ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type1")), + (b"BaseFont".to_vec(), Object::name("Helvetica")), + ] + .into_iter() + .collect::(), + ); + let s1 = doc.add(Stream::from_raw(Dict::new(), first_piece.to_vec())); + // Line 1 of s2 draws sideways ("HIDDEN") and 4 characters reading up; line 2 draws upright ("SHOWN"). + let s2 = doc.add(Stream::from_raw( + Dict::new(), + b"BT /F1 18 Tf 0 1 -1 0 50 50 Tm (HIDDEN) Tj ET\nBT /F1 18 Tf 1 0 0 1 20 100 Tm (SHOWN) Tj ET".to_vec(), + )); + let fonts: Dict = [(b"F1".to_vec(), Object::Ref(font))].into_iter().collect(); + let res: Dict = [(b"Font".to_vec(), Object::Dict(fonts))].into_iter().collect(); + let pages_root = doc.add(Object::Null); + let page = doc.add( + [ + (b"Type".to_vec(), Object::name("Page")), + (b"Parent".to_vec(), Object::Ref(pages_root)), + (b"MediaBox".to_vec(), Object::Array([0.0, 0.0, 200.0, 300.0].into_iter().map(Object::Real).collect())), + (b"Resources".to_vec(), Object::Dict(res)), + (b"Contents".to_vec(), Object::Array(vec![Object::Ref(s1), Object::Ref(s2)])), + ] + .into_iter() + .collect::(), + ); + doc.set( + pages_root, + Object::Dict( + [ + (b"Type".to_vec(), Object::name("Pages")), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(), + ), + ); + let cat = doc.add([(b"Type".to_vec(), Object::name("Catalog")), (b"Pages".to_vec(), Object::Ref(pages_root))].into_iter().collect::()); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(cat)); + doc + }; + + let form_strings = |imposed: Vec| -> Vec> { + let out = Document::open(Arc::new(imposed)).unwrap(); + let page = &pdfcraft_model::pages(&out)[0]; + let res = out.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = out.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let form_obj = out.resolve(xo.get(b"P0").unwrap()); + let Object::Stream(form) = &*form_obj else { panic!("form stream") }; + let body = form.decoded().unwrap(); + pdfcraft_content::parse(&body) + .ops + .into_iter() + .filter(|op| op.op == b"Tj") + .filter_map(|op| op.operands.first().and_then(Object::as_string).map(|s| s.bytes.to_vec())) + .collect() + }; + + // Without a newline at the end of s1, `% comment` swallows line 1 of s2 (`(HIDDEN) Tj`). + let commented = build_doc(b"q Q % trailing comment"); + assert!(!text_reads_up(&commented, 0), "commented-out vertical text on piece 2's first line is not counted"); + let printed = impose(&commented, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); + assert_eq!(form_strings(printed), [b"SHOWN".to_vec()], "impose does not un-comment piece 2's first line"); + + // Control: when s1 terminates the comment with a newline, line 1 of s2 is drawn and counted. + let with_newline = build_doc(b"q Q % trailing comment\n"); + assert!(text_reads_up(&with_newline, 0), "6 vertical vs 5 horizontal chars when line 1 is not commented out"); + let printed_ctrl = impose(&with_newline, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); + assert_eq!(form_strings(printed_ctrl), [b"HIDDEN".to_vec(), b"SHOWN".to_vec()]); +} diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 49593855e2..8d1515d8a4 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -20,4 +20,5 @@ pdfcraft-xfa = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +pdfcraft-filters = { workspace = true } pdfcraft-testkit = { workspace = true } diff --git a/crates/redact/README.md b/crates/redact/README.md index 34c9f4fe1f..8383f0119f 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -18,10 +18,15 @@ clear_marks(&mut doc, None)?; // remove marks without app more than a fifth of its size (or a point) both ways; zero-size text goes when its origin is 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). Codecs PdfCraft can't - re-encode (DCT, JPX, JBIG2, CCITT) are removed whole (fail-closed); inline images under an - area are removed. Images with separate `/SMask` or `/Mask` data, and images whose data decodes - to more than twice their declared raster (plus a byte per row and 4 KiB), are removed whole; + (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax as the last + filter, decoded through `Document::decoded_image_within`; `/DecodeParms` given by reference, + numbers inside included, count). Codecs redaction doesn't decode (DCT, JPX, JBIG2) are removed + whole (fail-closed), and so is Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's + renderer draws some of its layouts (the fill ending each end-of-line code on a byte) as other + rows than the decoder reads, so a mark over what is shown needn't cover what is cleared; inline images under an area are removed. Images with separate `/SMask` + or `/Mask` data, and images whose data decodes to more than three times their declared raster + and a byte per row, plus 4 KiB (ASCIIHex text broken into lines under Flate is a little over + twice; for a fax image's code, about a byte per pixel), are removed whole; cleared copies drop `/Alternates` so alternate pixels cannot survive; - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 601700f257..f662849c12 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -1,8 +1,10 @@ //! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel //! whose footprint intersects a region is set to zero (no ink for image masks), and the result is //! written as a new Flate image; the original object is untouched (other pages may use it). -//! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the -//! caller removes the whole image instead; that is fail-closed. +//! CCITT fax images decode through `Document::decoded_image_within`, but for Group 3 on bytes, +//! which PdfCraft's renderer may draw as other rows. Images whose codec PdfCraft can't decode +//! (DCT, JPX, JBIG2), and Group 3 on bytes, return `None`, and the caller removes the whole image +//! instead; that is fail-closed. use pdfcraft_content::{Matrix, overlaps}; use pdfcraft_cos::{Document, Object, Stream}; @@ -51,15 +53,29 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" | b"RunLengthDecode" | b"RL" ) }; - let ok = match s.dict.get(b"Filter") { - None => true, - Some(Object::Name(n)) => supported(n), - Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some_and(supported)), - _ => false, + // A CCITT fax image (its last filter) decodes through `Document::decoded_image_within`, after the + // filters before it, so a scan stored as Group 4 is cleared like any other image. + let fax = |f: &Object| f.as_name().is_some_and(|n| matches!(n, b"CCITTFaxDecode" | b"CCF")); + let readable = |f: &Object| f.as_name().is_some_and(supported); + let (ok, is_fax) = match s.dict.get(b"Filter") { + None => (true, false), + Some(f @ Object::Name(_)) => (readable(f) || fax(f), fax(f)), + Some(Object::Array(a)) => match a.split_last() { + Some((last, before)) if fax(last) => (before.iter().all(readable), true), + _ => (a.iter().all(readable), false), + }, + _ => (false, false), }; if !ok { return None; } + // Group 3 on bytes is removed whole for now. The decoder reads it by its end-of-line codes + // when the data starts with one, but PdfCraft's renderer draws such rows (the fill ending + // each code on a byte) as other rows, so the pixels a mark covers on screen needn't be the + // ones cleared. + if is_fax && doc.fax_params(s).is_none_or(|p| p.k >= 0 && p.encoded_byte_align) { + return None; + } 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; @@ -78,17 +94,29 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let row_bits = row.checked_mul(8)?; bytes.checked_mul(8)?; // Decode no further than the raster can need: a filter stage may legitimately produce up to - // twice it (ASCIIHex text under Flate) plus a PNG predictor's byte per row. Data past that - // can only be a decompression bomb; the image is then removed whole. - let allowance = bytes.checked_add(h)?.checked_mul(2)?.checked_add(4096)?; - let mut data = s.decoded_within(allowance).ok()?; + // three bytes for each of it and a PNG predictor's byte per row (ASCIIHex text under Flate, + // two digits a byte broken into lines or spaced), as the optimizer allows. A fax image's + // code, which the filters before its decoder produce, takes at most about a byte per pixel + // plus each row's end. Data past that can only be a decompression bomb; the image is then + // removed whole. + let allowance = if is_fax { + w.checked_mul(h)?.checked_add(h.checked_mul(4)?)?.checked_add(4096)? + } else { + bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)? + }; + // Through the document, which reads `/DecodeParms` given by reference (`Stream::decoded*` + // alone would skip such a predictor, and the cleared bytes would then miss the covered pixels). + let mut data = doc.decoded_image_within(s, allowance).ok()?; if data.len() < bytes { return None; } 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 { - let inverted = s.dict.get(b"Decode").and_then(Object::as_array).and_then(|d| d.first()).and_then(Object::as_f64) == Some(1.0); + // 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)); + let inverted = first.as_deref().and_then(Object::as_f64) == Some(1.0); !inverted } else { false diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c072d19db7..125f00fc5c 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -549,6 +549,193 @@ fn redaction_clears_pixels_intersected_without_covering_their_centres() { } } +/// An image whose PNG predictor is given by reference has its covered pixel cleared, and only +/// that one, whichever part is the reference: the dictionary, an array's entry, or the number +/// itself. `Stream::decoded` reads only a direct `/DecodeParms` with direct numbers, so the row +/// tags stayed in the data, the cleared byte drifted off the covered pixel and the covered sample +/// survived. +#[test] +fn an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared() { + // Two rows of four grey samples, each behind a PNG "None" row tag. + let predicted = pdfcraft_filters::encode_flate(&[0, 10, 20, 30, 40, 0, 50, 60, 70, 80]); + let parms = "/Predictor 15 /Colors 1 /BitsPerComponent 8 /Columns 4"; + for (decode_parms, object8) in [ + ("8 0 R".to_owned(), format!("<< {parms} >>")), + ("[8 0 R]".to_owned(), format!("<< {parms} >>")), + ("<< /Predictor 8 0 R /Colors 1 /BitsPerComponent 8 /Columns 4 >>".to_owned(), "15".to_owned()), + ] { + let image = format!( + "/Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /DecodeParms {decode_parms}" + ); + let mut doc = one_page(b"q 40 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&image, &predicted), object8.into_bytes()]); + // The top row's second sample. + mark(&mut doc, 0, &[[12.0, 12.0, 18.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.images_cleared, 1, "{decode_parms}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(vec![10, 0, 30, 40, 50, 60, 70, 80]), "{decode_parms}"); + } +} + +/// A CCITT fax image (a scan the optimizer or Create PDF stored as Group 4) has its covered +/// pixels cleared in a copy, like any image PdfCraft can decode, instead of being removed whole: +/// whether it sits behind another filter, with its parameters given by reference, or is a mask, +/// its decode array inverting it given directly, by reference or with its first number so. +#[test] +fn a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed() { + // 16 × 2, all 1 bits (white), or all 0 bits (a mask painting everywhere); the left half of + // the top row is covered. + let g4 = pdfcraft_filters::encode_g4(&[0xFF; 4], 16, 2).unwrap(); + let painted = pdfcraft_filters::encode_g4(&[0x00; 4], 16, 2).unwrap(); + let hex: Vec = g4.iter().map(|b| format!("{b:02x}")).collect::().bytes().chain(*b">").collect(); + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1"; + let parms = "<< /K -1 /Columns 16 /Rows 2 >>"; + for (dict, data, extra, cleared) in [ + (format!("{image} /ColorSpace /DeviceGray /Filter /CCITTFaxDecode /DecodeParms {parms}"), g4.clone(), vec![], [0x00, 0xFF, 0xFF, 0xFF]), + ( + format!("{image} /ColorSpace /DeviceGray /Filter [/ASCIIHexDecode /CCITTFaxDecode] /DecodeParms [null 8 0 R]"), + hex, + vec![parms.as_bytes().to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + // A mask's 0 bits paint: cleared bits are set to 1, which paints nothing. + (format!("{image} /ImageMask true /Filter /CCITTFaxDecode /DecodeParms {parms}"), painted, vec![], [0xFF, 0x00, 0x00, 0x00]), + // With /Decode [1 0] a mask's 1 bits paint, and cleared bits are 0. + (format!("{image} /ImageMask true /Decode [1 0] /Filter /CCITTFaxDecode /DecodeParms {parms}"), g4.clone(), vec![], [0x00, 0xFF, 0xFF, 0xFF]), + ( + format!("{image} /ImageMask true /Decode 8 0 R /Filter /CCITTFaxDecode /DecodeParms {parms}"), + g4.clone(), + 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"1".to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + ] { + let mut objs = vec![stream(&dict, data)]; + objs.extend(extra); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{dict}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1").as_deref(), Some(&cleared[..]), "{dict}"); + } +} + +/// Images whose data is more than twice their raster before its last filter are cleared, not +/// taken for decompression bombs: grey stored as ASCIIHex text broken into lines under Flate (two +/// digits a byte and a line break every 64), and RGB behind an array of filters, which a fax +/// image's bound (a byte a pixel) would refuse. +#[test] +fn images_behind_ascii_hex_in_lines_or_a_filter_array_are_cleared() { + let (w, h) = (1000usize, 200usize); + let grey: Vec = (0..w * h).map(|i| (i % 251) as u8).collect(); + let digits: Vec = grey.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + let mut lines: Vec = digits.chunks(64).flat_map(|line| line.iter().copied().chain(*b"\n")).collect(); + lines.push(b'>'); + let rgb: Vec = (0..100 * 100 * 3).map(|i| (i % 253) as u8).collect(); + let rgb_hex: Vec = pdfcraft_filters::encode_flate(&rgb).iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); + for (dict, data) in [ + ( + format!( + "/Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter [/FlateDecode /ASCIIHexDecode]" + ), + pdfcraft_filters::encode_flate(&lines), + ), + ( + "/Type /XObject /Subtype /Image /Width 100 /Height 100 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter [/ASCIIHexDecode /FlateDecode]" + .to_string(), + rgb_hex, + ), + ] { + let mut doc = one_page(b"q 100 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + mark(&mut doc, 0, &[[0.0, 0.0, 10.0, 10.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{dict}"); + } +} + +/// A fax image that can't be decoded in full is removed whole, as any other image is: one whose +/// code is cut short, and one behind a filter that inflates past what a code for its pixels can +/// take (a decompression bomb, which `decoded_image` alone would undo up to a gigabyte). +#[test] +fn a_fax_image_that_cannot_be_decoded_in_full_is_removed_whole() { + let g4 = pdfcraft_filters::encode_g4(&[0xFF; 4], 16, 2).unwrap(); + // A busy picture's code, cut off in its first row. + let busy = pdfcraft_filters::encode_g4(&[0xAA, 0xAA, 0x55, 0x55], 16, 2).unwrap(); + let short = busy.get(..1).unwrap().to_vec(); + // The real code, then four megabytes of padding the fax decoder would never read. + let mut padded = g4.clone(); + padded.resize(4 << 20, 0); + let bomb = pdfcraft_filters::encode_flate(&padded); + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1"; + let parms = "<< /K -1 /Columns 16 /Rows 2 >>"; + for (filters, data) in [("/CCITTFaxDecode /DecodeParms", short), ("[/FlateDecode /CCITTFaxDecode] /DecodeParms [null", bomb)] { + let close = if filters.ends_with("[null") { "]" } else { "" }; + let dict = format!("{image} /Filter {filters} {parms}{close}"); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (0, 1), "{dict}"); + } +} + +/// Group 3 on bytes is removed whole: the renderer draws some of its layouts as other rows than +/// the decoder reads, so the pixels a mark covers on screen needn't be the ones cleared. Group 3 +/// off bytes and Group 4 on bytes are cleared. Each picture is 16 × 2 white, each row a white run +/// of 16 (`101010`): off bytes; on bytes with no end-of-line codes; on bytes after codes whose fill +/// ends each on a byte, at `/K 0` and at `/K 1` (a 1 after each code), `/EncodedByteAlign` given by +/// reference there; and Group 4 rows on bytes, each the one code 1 (vertical, no change). +#[test] +fn group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared() { + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /CCITTFaxDecode"; + for (k, on_bytes, parms, data, extra, removed) in [ + (0, false, "<< /K 0 /Columns 16 >>", vec![0xAA, 0xA0], vec![], false), + (0, true, "<< /K 0 /Columns 16 /EncodedByteAlign true >>", vec![0xA8, 0xA8], vec![], true), + (0, true, "<< /K 0 /Columns 16 /EncodedByteAlign true >>", vec![0x00, 0x01, 0xA8, 0x00, 0x01, 0xA8], vec![], true), + (1, true, "<< /K 1 /Columns 16 /EncodedByteAlign 8 0 R >>", vec![0x00, 0x01, 0xD4, 0x00, 0x01, 0xD4], vec![b"true".to_vec()], true), + (-1, true, "<< /K -1 /Columns 16 /EncodedByteAlign true >>", vec![0x80, 0x80], vec![], false), + ] { + let p = pdfcraft_filters::CcittParams { k, columns: 16, encoded_byte_align: on_bytes, ..Default::default() }; + assert_eq!(pdfcraft_filters::decode_ccitt(&data, &p, 2).unwrap(), [0xFF; 4], "{parms}: the picture is white"); + let dict = format!("{image} /DecodeParms {parms}"); + let mut objs = vec![stream(&dict, data)]; + objs.extend(extra); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + if removed { + assert_eq!((report.images_cleared, report.images_removed), (0, 1), "{parms}"); + } else { + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{parms}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1").as_deref(), Some(&[0x00, 0xFF, 0xFF, 0xFF][..]), "{parms}"); + } + } +} + +/// A busy fax image is cleared, though its code is bigger than three times its raster (the bound +/// for other images): a code takes up to about a byte per pixel, and the fax image gets that much. +#[test] +fn a_busy_fax_image_behind_another_filter_is_cleared() { + // 256 × 256, rows of a one-pixel checkerboard between white ones: each checkered row is coded + // afresh against the white one above it, about four bits a pixel. + let rows: Vec = (0..256).flat_map(|y| [if y % 2 == 0 { 0xAA } else { 0xFF }; 32]).collect(); + let g4 = pdfcraft_filters::encode_g4(&rows, 256, 256).unwrap(); + let raster = 32 * 256; + assert!(g4.len() > (raster + 256) * 3 + 4096, "{} bytes: within the bound for other images", g4.len()); + let dict = "/Type /XObject /Subtype /Image /Width 256 /Height 256 /ColorSpace /DeviceGray /BitsPerComponent 1 \ + /Filter [/FlateDecode /CCITTFaxDecode] /DecodeParms [null << /K -1 /Columns 256 /Rows 256 >>]"; + let mut doc = one_page(b"q 256 0 0 256 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, pdfcraft_filters::encode_flate(&g4))]); + mark(&mut doc, 0, &[[0.0, 0.0, 10.0, 10.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0)); +} + /// 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. diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index a4e4404e6b..11266f057e 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -7039,3 +7039,106 @@ fn test_clip_only_text_raster_preserves_clipping_without_painting() { assert!(pixels.contains(&[0, 0, 255, 255]), "fill shows through the 7 Tr clip"); assert!(pixels.contains(&white), "the clip is text-shaped, not the whole page"); } + +/// A malformed token in a content stream used to end the stream there, so the renderer, text +/// extraction, Find and search-based redaction lost the rest of the page while the edit +/// reader, MuPDF and pdftotext read it (#3565, #3568). Each token below is now stepped over. +#[test] +fn a_malformed_token_does_not_end_the_content_stream() { + // One token between two text objects. `BEFORE` is drawn before it, `AFTER` after. + let page = |token: &str| { + let stream = format!("BT /F1 8 Tf 2 30 Td (BEFORE) Tj ET {token} BT /F1 8 Tf 2 10 Td (AFTER) Tj ET"); + 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 60 40] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(stream.as_bytes()); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(text.error.is_none(), "{token}: {:?}", text.error); + text.text.expect("a text layer").plain_text() + }; + + for token in [ + // A `re` with three operands: the typed iterator used to abort on a path operator it + // could not dispatch, which is what #3568 reports. + "20 10 10 re", + // Eleven operands: under the 256 the stack holds, so this one runs on its own last + // operand. Past the limit is covered by + // `an_operator_past_the_operand_limit_reads_its_own_operands` below. + "0 0 0 0 0 0 0 0 0 0 0 g", + // A stray delimiter: the lexer could not read an operator and jumped to the end. + ")", + // The same with no space after it. Stepping over the regular characters that follow a + // delimiter ate the next operator, so the page lost its second text object + // (#3636 review); only the delimiter is stepped over. + ")BT ET", + // A malformed `#` escape in a name, so neither an object nor an operator read. + "/C#2 gs", + // And the well-formed forms still work. + "20 10 10 5 re n", + "0.5 g", + ] { + let text = page(token); + assert!(text.contains("BEFORE"), "{token}: {text:?}"); + assert!(text.contains("AFTER"), "{token}: the rest of the page was lost: {text:?}"); + assert!(text.find("BEFORE") < text.find("AFTER"), "{token}: the page is read in order: {text:?}"); + } +} + +/// An operator preceded by more operands than the stack holds is applied to its own operands, +/// which are the ones nearest it, rather than to whichever of the strays survived. The parser +/// had a branch meant to drop such an instruction whole, dead because `Stack::push` always +/// reported success (noted in review of #3636); reviving it loses legitimate content, which +/// `an_operator_with_hundreds_of_operands_does_not_end_the_content` measures, so the dead branch +/// is gone and this pins what the ring buffer does instead. +#[test] +fn an_operator_past_the_operand_limit_reads_its_own_operands() { + // The strays are `1` and the operator's own operand is `0`, so the two answers differ: white + // if the operator read a stray, black if it read its own. + let page = |strays: usize| { + let stream = format!("{}0 g 0 0 20 20 re f", "1 ".repeat(strays)); + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{stream}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + stream.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{strays} strays: {:?}", p.error); + p.rgba.as_chunks::<4>().0.to_vec() + }; + let all_black = |pixels: &[[u8; 4]]| pixels.iter().all(|p| *p == [0, 0, 0, 255]); + + // None, a few, the limit, and past it: the fill is the black the `g` asked for every time. + for strays in [0, 5, 255, 256, 257, 600] { + assert!(all_black(&page(strays)), "{strays} stray operands: the `g` reads its own operand"); + } +} + +/// Content after an inline image whose data holds `EI` is still read. This pins the property +/// but does **not** exercise the look-ahead gating added for #3636's review: accepting a false +/// `EI` truncates the image, while the text after the real one is read either way, and this +/// assertion passes with the gating removed. Showing the truncation needs valid ASCII85 data +/// that contains `EI`, which this fixture does not have. +#[test] +fn content_after_an_inline_image_holding_ei_is_still_read() { + // An ASCII85 image whose data holds `EI\n`, then an `re` with three operands and twenty + // `q`s -- enough steps to satisfy the look-ahead's counter if the bad `re` were skipped -- + // before the real `EI`. `AFTER` is drawn once the image is over. + let stream = b"q 100 0 0 100 10 10 cm BI /W 4 /H 4 /CS /G /BPC 8 /F /A85 ID EI +20 10 10 re q q q q q q q q q q q q q q q q q q q q EI Q BT /F1 8 Tf 2 4 Td (AFTER) Tj ET" + .to_vec(); + 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 120 120] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n" + .to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(&stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(text.error.is_none(), "{:?}", text.error); + let page = text.text.expect("a text layer").plain_text(); + // The image ran to the real `EI`, so the text after it is the page's only text: the first + // `EI` inside the data was not taken for the end. + assert!(page.contains("AFTER"), "the content after the image was lost: {page:?}"); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 054262ee35..50257d8248 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -385,7 +385,7 @@ pub use layout::layout; use layout::{from_upright, to_upright}; use layout::{is_rtl, union}; #[cfg(test)] -use order::{Run, Scratch, collide, restarts_run}; +use order::{Run, Scratch, Seg, collide, restarts_run}; #[cfg(test)] mod tests; diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index 00b45e266c..99afc0f8a3 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -1,5 +1,5 @@ use super::device::{DIRECTION_TOLERANCE, Drawn, WORD_GAP}; -use super::order::{NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, place_glyphs, redraws, restarts_run, reverse_rtl}; +use super::order::{NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, is_script_on, place_glyphs, redraws, restarts_run, reverse_rtl}; use super::{HashMap, PageText, TextGlyph}; /// Glyphs in reading order, as indices into the page's glyphs, with their lines and word gaps. @@ -177,14 +177,19 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { let mut furthest_progress = 0.0; for i in 0..n { let g = glyphs[i].rect; + let hi = height(i); let cont = segs.last().is_some_and(|s| { let Some(p) = s.first.end.checked_sub(1) else { return false }; - let ph = height(p).min(height(i)); + let ph = height(p).min(hi); + let sh = s.h.min(hi); let gap = g[0] - glyphs[p].rect[2]; // A sub- or superscript is a smaller glyph on a shifted baseline: its em box centre sits off the line by // more than half its own height, but by less than half the height of the full-size glyph beside it. - let centre_limit = if gap.abs() < ph { height(p).max(height(i)) * 0.5 } else { ph * 0.5 }; - (cy(i) - cy(p)).abs() < centre_limit && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0 + let centre_limit = if gap.abs() < ph { height(p).max(hi) * 0.5 } else { ph * 0.5 }; + let same_line = ((cy(i) - cy(p)).abs() < centre_limit && gap < ph * 3.0) + || ((cy(i) - s.cy()).abs() < sh * 0.3 && (s.h / hi - 1.0).abs() < 0.35 && gap < sh * 3.0); + let on_seg = gap < s.h * 1.2 && (is_script_on(&s.bbox, s.h, &g, hi) || is_script_on(&g, hi, &s.bbox, s.h)); + (same_line || on_seg) && g[0] > glyphs[p].rect[0] - sh * 2.0 }); let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress)); if cont @@ -192,11 +197,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { && let Some(s) = segs.last_mut() { s.first.end = i + 1; - union(&mut s.bbox, &g); - s.h = s.h.max(height(i)); + s.extend_box(&g, hi); furthest_progress = furthest_progress.max((g[0] - run_start_x).abs()).max((g[2] - run_start_x).abs()); } else { - segs.push(Seg::new(Run { start: i, end: i + 1 }, g, height(i))); + segs.push(Seg::new(Run { start: i, end: i + 1 }, g, hi)); run_start_x = g[0]; furthest_progress = 0.0; } @@ -245,7 +249,8 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { kept_runs }; segs.truncate(retained); - // Merge same-baseline segments that nearly touch. + // Merge same-baseline segments that nearly touch, then attach any remaining superscript or + // subscript segments to the closest host line segment they sit on. let mut merged = true; while merged { merged = false; @@ -266,6 +271,30 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { } } } + if !merged { + let mut b = 0; + while b < segs.len() { + let sb = &segs[b]; + let best_host = (0..segs.len()) + .filter(|&a| { + if a == b { + return false; + } + let sa = &segs[a]; + let gap = (sb.bbox[0] - sa.bbox[2]).max(sa.bbox[0] - sb.bbox[2]); + sa.bbox[2] - sa.bbox[0] > sb.bbox[2] - sb.bbox[0] && gap < sa.h * 1.2 && is_script_on(&sa.bbox, sa.h, &sb.bbox, sb.h) + }) + .min_by(|&a1, &a2| (segs[a1].cy() - sb.cy()).abs().total_cmp(&(segs[a2].cy() - sb.cy()).abs())); + if let Some(a) = best_host { + let sb = segs.remove(b); + let a = if b < a { a - 1 } else { a }; + segs[a].merge(sb); + merged = true; + } else { + b += 1; + } + } + } } // 2. Blocks. diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index d8cb65f32c..efcabb5f1a 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -1,5 +1,5 @@ use super::TextGlyph; -use super::layout::{is_rtl, union}; +use super::layout::is_rtl; /// Glyphs `start..end` of the glyph list [`arrange`] lays out, drawn one after another. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -37,6 +37,32 @@ struct SegExtra { more: Vec, } +/// Whether `(script_bbox, script_h)` is a superscript or subscript on `(host_bbox, host_h)`. +pub(super) fn is_script_on(host_bbox: &[f32; 4], host_h: f32, script_bbox: &[f32; 4], script_h: f32) -> bool { + let host_cy = (host_bbox[1] + host_bbox[3]) / 2.0; + let script_cy = (script_bbox[1] + script_bbox[3]) / 2.0; + let script_w = script_bbox[2] - script_bbox[0]; + let host_w = host_bbox[2] - host_bbox[0]; + let smaller = script_h >= host_h * 0.45 && script_w < host_h * 3.0 && (script_h < host_h * 0.9 || (script_h <= host_h && host_w > script_w)); + if !smaller { + return false; + } + if script_cy < host_cy { + // Superscript: raised above the host baseline, with its bottom inside the host line. + host_cy - script_cy >= host_h * 0.12 + && host_cy - script_cy < host_h * 0.7 + && host_bbox[3] - script_bbox[3] >= host_h * 0.15 + && script_bbox[3] > host_bbox[1] + host_h * 0.25 + } else { + // Subscript: lowered below the host baseline, with its centre still inside the host line. + script_cy - host_cy >= host_h * 0.12 + && script_cy - host_cy < host_h * 0.5 + && script_bbox[3] > host_bbox[3] + && host_bbox[3] > script_bbox[1] + script_h * 0.5 + && script_bbox[1] >= host_bbox[1] - script_h * 0.2 + } +} + impl Seg { pub(super) fn new(first: Run, bbox: [f32; 4], h: f32) -> Self { Seg { first, extra: None, bbox, h } @@ -50,9 +76,25 @@ impl Seg { std::iter::once(self.first).chain(self.extra.iter().flat_map(|e| e.more.iter().copied())) } + /// Extend `self`'s box and height with `(bbox, h)`. A smaller script (superscript or + /// subscript) widens the line horizontally without pulling its vertical baseline band up or + /// down; when a line begins with a script and continues with taller body text, the body text's + /// vertical band becomes the line's. + pub(super) fn extend_box(&mut self, bbox: &[f32; 4], h: f32) { + if h > self.h * 1.15 || is_script_on(bbox, h, &self.bbox, self.h) { + self.bbox[1] = bbox[1]; + self.bbox[3] = bbox[3]; + } else if !(self.h > h * 1.15 || is_script_on(&self.bbox, self.h, bbox, h)) { + self.bbox[1] = self.bbox[1].min(bbox[1]); + self.bbox[3] = self.bbox[3].max(bbox[3]); + } + self.bbox[0] = self.bbox[0].min(bbox[0]); + self.bbox[2] = self.bbox[2].max(bbox[2]); + self.h = self.h.max(h); + } + pub(super) fn merge(&mut self, other: Seg) { - union(&mut self.bbox, &other.bbox); - self.h = self.h.max(other.h); + self.extend_box(&other.bbox, other.h); let extra = self.extra.get_or_insert_with(Default::default); extra.more.push(other.first); if let Some(o) = other.extra { @@ -274,8 +316,10 @@ pub(super) fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f } budget -= 1; let (ra, rb) = (union_find(&mut sc.parent, ri), union_find(&mut sc.parent, rj)); + let hj = height(j); + let script = is_script_on(&glyphs[i].rect, hi, &glyphs[j].rect, hj) || is_script_on(&glyphs[j].rect, hj, &glyphs[i].rect, hi); let overlap = x1.min(glyphs[j].rect[2]) - x0(j); - if ra != rb && overlap > RUN_COLLISION * hi.min(height(j)) { + if ra != rb && !script && overlap > RUN_COLLISION * hi.min(hj) { sc.parent[ra] = rb; collided = true; } diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 836157e738..a1af42a8d0 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1760,6 +1760,137 @@ fn truetype_without_an_encoding_reads_its_codes_as_win_ansi() { assert_eq!(ocr_layer_text(32, true), expected, "nonsymbolic, /ToUnicode for ASCII only"); } +/// Issue #2929: a footnote reference (superscript) on a line stays in that line instead of +/// splitting out as a raised segment that steals the block's tail and pushes the host line +/// after the paragraph. +#[test] +fn paragraph_selection_includes_lines_with_footnote_references() { + for inline in [true, false] { + let ops = if inline { + "BT /F1 12 Tf 1 0 0 1 40 300 Tm (First line of the paragraph) Tj \ + 1 0 0 1 40 285 Tm (Second line with a note) Tj \ + /F1 8 Tf 5.5 Ts (1) Tj 0 Ts \ + /F1 12 Tf ( and more text) Tj \ + 1 0 0 1 40 270 Tm (Third line of the paragraph) Tj \ + /F1 7 Tf 1 0 0 1 40 104 Tm (1) Tj \ + /F1 10 Tf 1 0 0 1 47 100 Tm (Footnote at the bottom) Tj ET" + } else { + // Superscripts drawn in a separate pass after the body text. + "BT /F1 12 Tf 1 0 0 1 40 300 Tm (First line of the paragraph) Tj \ + 1 0 0 1 40 285 Tm (Second line with a note) Tj \ + 1 0 0 1 173 285 Tm (and more text) Tj \ + 1 0 0 1 40 270 Tm (Third line of the paragraph) Tj \ + /F1 10 Tf 1 0 0 1 47 100 Tm (Footnote at the bottom) Tj \ + /F1 8 Tf 1 0 0 1 165 290.5 Tm (1) Tj \ + /F1 7 Tf 1 0 0 1 40 104 Tm (1) Tj ET" + }; + let text = page_glyphs(ops); + let start = text.nearest(40.0, 95.0).unwrap(); + let end = text.nearest(185.0, 128.0).unwrap(); + assert_eq!( + text.text_of(start..end + 1), + "First line of the paragraph\nSecond line with a note1 and more text\nThird line of the paragraph", + "inline={inline}" + ); + assert_eq!(text.line_rects(start..end + 1).len(), 3, "three selected lines, inline={inline}"); + assert_eq!( + text.plain_text(), + "First line of the paragraph\nSecond line with a note1 and more text\nThird line of the paragraph\n1 Footnote at the bottom", + "inline={inline}" + ); + } + + // Superscript on the first line, multi-character bracketed reference `[12]` on the second + // line, and subscript on the third line (both inline and out-of-order). + for inline in [true, false] { + let ops = if inline { + "BT /F1 12 Tf 1 0 0 1 40 300 Tm (Opening claim) Tj \ + /F1 8 Tf 5.5 Ts (1) Tj 0 Ts \ + /F1 12 Tf ( in the first line) Tj \ + 1 0 0 1 40 285 Tm (Middle citation) Tj \ + /F1 8 Tf 5.5 Ts ([12]) Tj 0 Ts \ + /F1 12 Tf ( continues here) Tj \ + 1 0 0 1 40 270 Tm (Formula H) Tj \ + /F1 8 Tf -3 Ts (2) Tj 0 Ts \ + /F1 12 Tf (O in the third line) Tj ET" + } else { + "BT /F1 12 Tf 1 0 0 1 40 300 Tm (Opening claim) Tj \ + 1 0 0 1 125 300 Tm (in the first line) Tj \ + 1 0 0 1 40 285 Tm (Middle citation) Tj \ + 1 0 0 1 135 285 Tm (continues here) Tj \ + 1 0 0 1 40 270 Tm (Formula H) Tj \ + 1 0 0 1 101 270 Tm (O in the third line) Tj \ + /F1 8 Tf 1 0 0 1 117 305.5 Tm (1) Tj \ + 1 0 0 1 117 290.5 Tm ([12]) Tj \ + 1 0 0 1 96.5 267 Tm (2) Tj ET" + }; + let text = page_glyphs(ops); + assert_eq!( + text.plain_text(), + "Opening claim1 in the first line\nMiddle citation[12] continues here\nFormula H2O in the third line", + "inline={inline}" + ); + assert_eq!(text.line_rects(0..text.glyphs.len()).len(), 3, "inline={inline}"); + } + + // Justified wide gap (18pt = 1.5 * h) after an inline superscript stays on the same line, + // and an out-of-order superscript kerned slightly over punctuation does not break the host + // word via overprint collision. + let justified = page_glyphs( + "BT /F1 12 Tf 1 0 0 1 40 300 Tm (Justified clause.) Tj \ + /F1 8 Tf 5.5 Ts (1) Tj 0 Ts \ + /F1 12 Tf 1 0 0 1 151 300 Tm (Next sentence on the same line) Tj \ + 1 0 0 1 40 285 Tm (Kerned clause.) Tj \ + 1 0 0 1 130 285 Tm (continues cleanly) Tj \ + /F1 8 Tf 1 0 0 1 118 290.5 Tm (2) Tj ET", + ); + assert_eq!(justified.plain_text(), "Justified clause.1 Next sentence on the same line\nKerned clause.2 continues cleanly"); + assert_eq!(justified.line_rects(0..justified.glyphs.len()).len(), 2); + + // A line that begins with a raised superscript marker and continues with taller body text + // adopts the body text's vertical band (Seg::extend_box first branch) so a later + // out-of-order superscript on that line still measures against the body baseline. + let leading_script = page_glyphs( + "BT /F1 8 Tf 1 0 0 1 40 300 Tm 6 Ts (1) Tj 0 Ts \ + /F1 12 Tf ( See the appendix) Tj \ + 1 0 0 1 146 300 Tm (for details) Tj \ + /F1 8 Tf 1 0 0 1 139 303.6 Tm (2) Tj ET", + ); + assert_eq!(leading_script.plain_text(), "1 See the appendix2 for details"); + let marker = leading_script.glyphs.first().unwrap().rect; + let body = leading_script.glyphs.iter().find(|g| g.text == "S").unwrap().rect; + let last = leading_script.glyphs.last().unwrap().rect; + let center = |r: [f32; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + let (start_x, start_y) = center(marker); + let (end_x, end_y) = center(last); + let start = leading_script.nearest(start_x, start_y).unwrap(); + let end = leading_script.nearest(end_x, end_y).unwrap(); + assert_eq!(leading_script.text_of(start..end + 1), "1 See the appendix2 for details"); + let rects = leading_script.line_rects(start..end + 1); + assert_eq!(rects.len(), 1); + assert!(marker[1] < body[1] && marker[3] < body[3], "marker must be raised above the body"); + let close = |actual: f32, expected: f32| assert!((actual - expected).abs() < 0.001, "{actual} != {expected}"); + // Highlight rectangles cover the actual glyphs, including the raised marker. They do + // not expose the segment's baseline band used for line grouping. + for (actual, expected) in rects[0].into_iter().zip([marker[0], marker[1], last[2], body[3]]) { + close(actual, expected); + } + close(rects[0][3] - rects[0][1], 14.8); + close(body[3] - 0.2 * 12.0, 100.0); // 300 pt PDF baseline on a 400 pt page. + + // Pin that internal band too: replacing the first extend_box condition with `false` + // must fail here even when plain text and highlight rectangles remain unchanged. + let mut segment = Seg::new(Run { start: 0, end: 1 }, marker, marker[3] - marker[1]); + segment.extend_box(&body, body[3] - body[1]); + for (actual, expected) in segment.bbox.into_iter().zip([marker[0], body[1], body[2], body[3]]) { + close(actual, expected); + } + close(segment.h, 12.0); + close(segment.bbox[3] - segment.bbox[1], 12.0); + close(segment.bbox[3] - 0.2 * segment.h, 100.0); + close(segment.cy(), (body[1] + body[3]) / 2.0); +} + #[test] fn text_outside_the_crop_box_is_clipped() { use lopdf::{Document, Object, Stream, dictionary}; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 23bee9babc..425c2e26ce 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -31,6 +31,37 @@ impl Default for PdfCraftApp { } impl PdfCraftApp { + /// The window's title and, on the web, the page's, from the active document (#3640). Done in + /// `logic` too, which also runs while the window or browser tab is hidden: a document opened + /// then (a `?file=` download finishing) still names the page an embedding host reads. + fn sync_titles(&mut self, ctx: &egui::Context) { + // The window shows the active document's name (or title, if it asks for that), with • + // while it has unsaved changes, as the other Craft apps write it (#3640). Unsaved means + // what closing would ask about, so a draft not yet applied counts too. + let dirty = self.active.is_some_and(|i| self.has_unsaved_work(i)); + let mark = if dirty { " •" } else { "" }; + let titled = |name: Option| { + name.or_else(|| self.combine_showing().then(|| tl!("Combine files").to_owned())) + .map_or_else(|| "PdfCraft".to_owned(), |name| format!("{name}{mark} — PdfCraft")) + }; + let title = titled(self.active.and_then(|i| crate::canvas::tab_label(self, i))); + // The web page's title is what an embedding host reads to tell which file opened, so it + // names the file even when the document asks to show its own title. + let page_title = titled(self.active.and_then(|i| self.views.get(i)).and_then(|v| self.session.get(v.id)).map(|d| d.name.clone())); + if title != self.window_title { + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + self.window_title = title; + } + if page_title != self.page_title { + // eframe's web runner ignores Title: set the page's own. + #[cfg(target_arch = "wasm32")] + if let Some(document) = web_sys::window().and_then(|w| w.document()) { + document.set_title(&page_title); + } + self.page_title = page_title; + } + } + /// Construct an application with the default session, panels, theme and control state. pub fn new() -> Self { Self { @@ -73,6 +104,7 @@ impl PdfCraftApp { ui_scale_applied: None, flatten_fill_sign_on_save: false, fill_text_size: fill_sign::TEXT_SIZE, + fill_text_color: [0.0; 3], dialog: None, updates: updates::Updates::default(), printer_source: None, @@ -198,6 +230,7 @@ impl PdfCraftApp { field_table_draft: None, form_preview: false, window_title: String::new(), + page_title: String::new(), recovery: None, recoverable: Vec::new(), recovery_keys: Default::default(), @@ -527,6 +560,7 @@ impl eframe::App for PdfCraftApp { // Last, after everything that can open or answer the "save changes?" question. self.attend_save_prompt(ctx); self.sync_native_menu(ctx); + self.sync_titles(ctx); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { @@ -546,16 +580,7 @@ impl eframe::App for PdfCraftApp { for view in &mut self.views { view.begin_render_frame(); } - // The window shows the active document's name (or title, if it asks for that). - let title = self - .active - .and_then(|i| crate::canvas::tab_label(self, i)) - .or_else(|| self.combine_showing().then(|| tl!("Combine files").to_owned())) - .map_or_else(|| "PdfCraft".to_owned(), |name| format!("{name} — PdfCraft")); - if title != self.window_title { - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); - self.window_title = title; - } + self.sync_titles(&ctx); // The accessible window takes the same name, so a screen reader (AT-SPI on Linux, UIA on // Windows) can tell it apart (#4614). egui builds the root node afresh every pass. ctx.accesskit_node_builder(egui::accesskit_root_id(), |root| root.set_label(self.window_title.as_str())); diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index c94e77ffbb..79bd2fa258 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -127,6 +127,9 @@ impl PdfCraftApp { } "none" => None, _ => Some(Dialog::About), + }; + if let Some(Dialog::Marks(kind)) = self.dialog { + self.marks_draft.opened(kind); } } ("tools", _) if value == "full" => self.expand_all_tools(), diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index cdc696f95b..6021af64d8 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -44,12 +44,16 @@ impl PdfCraftApp { 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 - && !ctx.egui_wants_keyboard_input() + && (pages_owns_keyboard || !ctx.egui_wants_keyboard_input()) && ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A))) { - if self.is_preparing() { + if pages_owns_keyboard { + self.views[i].select_all_pages(); + } 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); } diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index f64ba6d892..e5eaae5863 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -139,6 +139,7 @@ impl PdfCraftApp { }); // Set apart: one more key inside the macro above passes serde_json's recursion limit. v["default_page_gaps"] = serde_json::Value::Bool(self.view_defaults.show_page_gaps); + v["fill_text_color"] = serde_json::json!(self.fill_text_color); v["redact_prefs"] = self.redact_defaults.to_json(); // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); @@ -261,6 +262,11 @@ impl PdfCraftApp { if let Some(size) = v["fill_text_size"].as_f64().and_then(fill_sign::valid_text_size) { self.fill_text_size = size; } + if let Ok(color) = serde_json::from_value::<[f64; 3]>(v["fill_text_color"].clone()) + && let Some(color) = fill_sign::valid_text_color(color) + { + self.fill_text_color = color; + } if let Some(prefs) = RedactPrefs::from_json(&v["redact_prefs"]) { self.redact_prefs = prefs.clone(); self.redact_defaults = prefs; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 91d2c41367..5269383b54 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -29,6 +29,7 @@ mod navigation; mod organize; pub(super) mod page_cursor; pub(super) mod page_pointer; +pub(super) mod page_router; pub mod scroll_speed; mod selection; pub(super) mod signature_drag; @@ -463,6 +464,8 @@ pub struct DocView { pub org_drag: Option>, /// Pages panel: the pages being dragged to a new place. pub panel_drag: Option>, + /// Transient keyboard owner for this document's Pages sidebar. + pub(crate) pages_focus_id: Option, /// Marquee Zoom / Snapshot: the rectangle being dragged (page, start), and a finished one. pub marquee: Option<(usize, Pos2)>, pub marquee_done: Option, @@ -473,6 +476,11 @@ pub struct DocView { pub(crate) signature_drag: crate::signature_drag::SignatureDrag, /// What the page modules claim for the cursor this frame; `document_area` sets it once. pub(crate) cursor: crate::page_cursor::PageCursor, + /// Who the press the pages hold belongs to. + pub(crate) router: crate::page_router::PageRouter, + /// Escape cancelled the press the pages hold: the page ignores it until the button is up, so + /// its release neither clicks nor ends a drag. + pub(crate) press_cancelled: bool, /// A non-edit action requested by the organize toolbar, handled by the app. pub pending_action: Option, /// The grid gap the next inserted files go to (set by a "+" between pages); otherwise they @@ -587,6 +595,7 @@ impl DocView { self.org_drag = None; self.panel_drag = None; self.selection_drag = false; + self.router.cancel(); self.auto_scroll.cancel(); self.prepare.cancel_pointer_gesture(); self.content.cancel_pointer_gesture(); @@ -720,12 +729,15 @@ impl DocView { sign: Default::default(), org_drag: None, panel_drag: None, + pages_focus_id: None, marquee: None, marquee_done: None, fill_text: None, fill_signature_page: None, signature_drag: Default::default(), cursor: Default::default(), + router: Default::default(), + press_cancelled: false, } } @@ -871,6 +883,7 @@ struct PageEnv<'a> { visible_pages: &'a Vec, y_shift: f32, fill_text_size: f64, + fill_text_color: [f64; 3], today: (i64, u32, u32), } @@ -1114,12 +1127,43 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< tool, units, fill_text_size, + fill_text_color, .. } = 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 (lines, images) = + if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; + let field_tool = match tool { + QuickTool::Field(f) => Some(f), + _ => None, + }; + // While a table is being set up, nothing else is drawn or moved. + let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); + let hits = PageHits { + page: i, + xf: &xf, + info, + tool, + pcx: &pcx, + form, + can_fill, + can_modify, + editing_content, + preparing, + field_tool, + prepare_allowed, + added, + lines: &lines, + images: &images, + clip: ui.clip_rect(), + }; + if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { + let at = hits.at(view, o); + view.router.press(i, at, tool); + } if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); } @@ -1131,7 +1175,8 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< && let Some(p) = ptr.within(xf.rect) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - if resp.clicked() { + // The tool's own click: nothing drawn over the page here holds the press. + if resp.clicked() && view.router.owner(i).is_none() { // Centred on the click, upright as the page is shown. let (vx, vy) = xf.screen_to_view(p); let (w, h) = kind.size(); @@ -1155,7 +1200,8 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< && let Some(p) = ptr.within(xf.rect) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - if resp.clicked() { + // The tool's own click: nothing drawn over the page here holds the press. + if resp.clicked() && view.router.owner(i).is_none() { // Centred on the click at its natural size (the engine sizes it). let (vx, vy) = xf.screen_to_view(p); let u = info.pages[i].view_to_user(vx, vy); @@ -1180,6 +1226,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< crate::fill_sign::PageInput { tool: ft, text_size: fill_text_size, + text_color: fill_text_color, signature: signature.as_ref(), initials: initials.as_ref(), preview: frame.signature_preview, @@ -1215,22 +1262,13 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< frame.out.open_boxes = true; } let on_field = if preparing { - let field_tool = match tool { - QuickTool::Field(f) => Some(f), - _ => None, - }; - // While a table is being set up, nothing else is drawn or moved. - let allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); - let o = crate::prepare::page_input(cx, form, field_tool, allowed, view); + let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view); frame.out.field_props |= o.properties; frame.out.field_placed |= o.placed; frame.out.open_table = frame.out.open_table.or(o.table); o.consumed || field_tool.is_some() } else { - tool == QuickTool::Select - && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, ptr, i, info, added, view)) - && !comments::select_grabs(&pcx, view) - && crate::forms_ui::page_input(cx, form, can_fill, view) + tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view) }; // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; @@ -1242,26 +1280,14 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< }; crate::redact_ui::page_input(cx, over_text, view) }; - let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, ptr, i, info, view); - let on_objects = !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); - let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { - let generation = doc.edit_generation(); - match view.edit_lines.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.text_blocks(i); - view.edit_lines.insert(i, (generation, l.clone())); - l - } - } - } else { - Vec::new() - }; + let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); + // Add text acts on the page's own paragraphs over them, and on the one its press was on until + // the button is up: a drag that leaves the box still ends, and no new box takes it over. let over_source = tool == QuickTool::AddText - && pointer.is_some_and(|p| { - !crate::content_ui::hits_added(&xf, info, i, added, p) - && lines.iter().any(|line| xf.user_rect(info, i, line.rect.map(|v| v as f32)).expand(2.0).contains(p)) - }); + && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. })) + || pointer.is_some_and(|p| { + !crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p) + })); let source_blocked = over_source && view.content.hold_blocked(); if over_source && resp.clicked() && !source_blocked { if let Some(edit) = view.content.finish_draft(added) { @@ -1288,18 +1314,9 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< // instead of vanishing until the pointer comes back. let others_own_pointer = on_objects || on_content; let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { - let generation = doc.edit_generation(); - let images = match view.edit_images.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.page_images(i); - view.edit_images.insert(i, (generation, l.clone())); - l - } - }; // Editable text takes precedence over background/overlapping artwork. Both // inventories are painted every frame, even when text owns the pointer. - let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer && !image_owns_pointer); + let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer); let image_cx = crate::edit_text_ui::ImageCx { ui, resp, @@ -1307,7 +1324,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< ptr, page: i, info, - allow_interaction: !others_own_pointer && (image_owns_pointer || !text_used), + allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), }; let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view, &mut frame.out.image_action); !others_own_pointer && (text_used || image_used) @@ -1322,9 +1339,133 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< view.links.context = Some((i, l.index)); } let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); + // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the + // last claim on a layer stands). + if frame.env.unobstructed + && let Some(p) = pointer + { + for h in hits.at(view, p) { + view.cursor.claim_with_tip(h.layer, h.icon, h.tip); + } + } PageInput { consumed, area_tool } } +/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. +fn edit_inventory( + view: &mut DocView, + doc: &pdfcraft_engine::Document, + i: usize, +) -> (Vec, Vec) { + let generation = doc.edit_generation(); + let lines = match view.edit_lines.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.text_blocks(i); + view.edit_lines.insert(i, (generation, l.clone())); + l + } + }; + let images = match view.edit_images.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.page_images(i); + view.edit_images.insert(i, (generation, l.clone())); + l + } + }; + (lines, images) +} + +/// What the page modules have under a point on one page, for the page router. +struct PageHits<'a> { + page: usize, + xf: &'a PageXform, + info: &'a DocInfo, + tool: QuickTool, + pcx: &'a comments::PageCx<'a>, + form: &'a [pdfcraft_engine::FormField], + can_fill: bool, + can_modify: bool, + /// Edit a PDF is open: added text and pictures can be picked up. + editing_content: bool, + preparing: bool, + field_tool: Option, + /// Prepare may draw and move fields (not while a table is being set up). + prepare_allowed: bool, + added: &'a [pdfcraft_engine::Added], + lines: &'a [pdfcraft_engine::TextBlock], + images: &'a [pdfcraft_engine::PageImage], + /// The pages' visible area (the picture's rotate knob stays inside it). + clip: Rect, +} + +impl PageHits<'_> { + /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text + /// on top of artwork takes the press), added content, the objects selected together and the + /// open paragraph's frame, the selected picture's grips over them all; then form fields + /// (Prepare's, being moved, over the comments; filled ones under added content and the + /// comments that move). + fn at(&self, view: &DocView, p: Pos2) -> Vec { + let PageHits { + page, + xf, + info, + tool, + pcx, + form, + can_fill, + can_modify, + editing_content, + preparing, + field_tool, + prepare_allowed, + added, + lines, + images, + clip, + } = *self; + let fields = if preparing { + crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) + } else if tool == QuickTool::Select { + crate::forms_ui::hit(xf, info, page, form, can_fill, p) + } else { + None + }; + // With Add text, only the paragraphs and pictures not under added content. + let editing = + can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p)); + let (pictures, paragraphs, objects, grips) = if editing { + let open = view.line_editor.as_ref().filter(|e| e.page == page); + let frame = open + .filter(|e| !e.has_changes()) + .and_then(|e| lines.get(e.block)) + .map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)); + // Add text acts on the page's paragraphs only in their boxes, edges included (it + // finishes the box being typed there, `over_source`): not on pictures, nor beside a + // paragraph, where a click starts a new box. + let edit_text = tool == QuickTool::EditText; + let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p); + ( + edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(), + on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(), + edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(), + edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(), + ) + } else { + (None, None, None, None) + }; + // Added text and pictures, over the page's own paragraphs and under the objects selected + // together. + let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); + let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); + [link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields] + .into_iter() + .flatten() + .collect() + } +} + /// Text layer: find matches, selection, I-beam and drag-to-select. fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { let PageEnv { info, selects_text, tool, .. } = frame.env; @@ -1426,8 +1567,10 @@ fn update_text_cursor(view: &mut DocView, gesture: &TextGesture<'_>) { } fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + // A press held by what keeps its drag (a link, a comment that moves) selects no text; one on + // text markup lets its drag select the text underneath. let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin)); - if gesture.ptr.pressed().is_some() && press_here && !gesture.over_link { + if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() { let now = ui.input(|input| input.time); let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist)); let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist); @@ -1442,7 +1585,7 @@ fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, view.selection = Some(Selection::run(gesture.page, first, last)); } } - if gesture.resp.drag_started() && press_here && !gesture.over_link { + if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() { view.actions_dismissed = None; view.selection_drag = true; let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); @@ -1514,7 +1657,7 @@ fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageTex && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) { view.selection = Some(Selection::run(gesture.page, first, last)); - } else if gesture.resp.clicked() && view.clicks < 2 && !gesture.over_link { + } else if gesture.resp.clicked() && view.clicks < 2 && !view.router.keeps_drag() { let shift = ui.input(|input| input.modifiers.shift); match ( view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span), @@ -1559,9 +1702,12 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_ } crate::image_pick::drop_stale(doc, view); crate::image_pick::paint(painter, &xf, i, info, view); - comments::page_after_text(resp, &pcx, view); - if tool == QuickTool::Redact && can_modify { - crate::redact_ui::after_text(resp, i, info, view); + // A cancelled press ends no selection: no markup or redaction is made from it. + if !view.press_cancelled { + comments::page_after_text(resp, &pcx, view); + if tool == QuickTool::Redact && can_modify { + crate::redact_ui::after_text(resp, i, info, view); + } } if input.area_tool { crate::redact_ui::paint(painter, i, view, pointer); @@ -1601,26 +1747,17 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_ paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields); // Links: the hand and the link's target, which count only for the tools that follow or // edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is - // open, even beside it. - if unobstructed && let Some(p) = pointer { - for l in info.links.iter().filter(|l| l.page == i) { - let sr = xf.user_rect(info, i, l.rect); - if sr.contains(p) { - let label = match &l.target { - LinkTarget::Page(n, _) => { - crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]) - } - LinkTarget::Uri(u) => u.clone(), - LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), - LinkTarget::RemoteGoTo { file, .. } => file.clone(), - LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), - }; - view.cursor.claim_with_tip(Layer::Link, egui::CursorIcon::PointingHand, Some(label)); - if resp.clicked() && tool == QuickTool::Select && !input.consumed { - frame.out.clicked_link = Some(l.target.clone()); - } - } - } + // open, even beside it. A click follows the link its press was on, released over it: one that + // slips off the link first follows nothing, as a button's does. + if unobstructed + && resp.clicked() + && tool == QuickTool::Select + && !input.consumed + && let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i) + && pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner) + && let Some(l) = info.links.get(index) + { + frame.out.clicked_link = Some(l.target.clone()); } // Comments say what they are with any tool, beside its cursor, as Acrobat's do. comments::describe(&pcx, view); @@ -1737,12 +1874,18 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i } let slot = PageSlot { i, r, xf }; - let input = page_tool_input(ui, view, frame, &slot); + // A cancelled press takes nothing until the button is up. + let input = if view.press_cancelled { PageInput { consumed: true, area_tool: false } } else { page_tool_input(ui, view, frame, &slot) }; let painter = ui.painter(); let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); + // Any other comment being moved: drawn where it goes, over the page that still shows it + // where it was. + let stale = view.signature_drag.moving_page().is_some_and(|p| view.page_out_of_date(p)); + view.signature_drag.prepare_move(ui.ctx(), doc, &view.comments, view.pending_edit.as_ref(), stale, scale); + view.signature_drag.paint_move(painter, &pcx, &view.comments, view.pending_edit.as_ref()); // Any press on the page ends Select All of the document: text read or not, any tool, // on a link too. @@ -1754,6 +1897,43 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i page_overlays(ui, view, frame, &slot, &input); } +/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows +/// links and the Link tool edits them (`Layer::reached_by`). +fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { + let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; + let label = match &l.target { + LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]), + LinkTarget::Uri(u) => u.clone(), + LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), + LinkTarget::RemoteGoTo { file, .. } => file.clone(), + LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), + }; + let owner = crate::page_router::Owner::Link { index }; + Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) }) +} + +/// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is +/// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The +/// frame of the release still belongs to the cancelled press. +fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) { + // Over once the button is up and its release seen, or when a new press comes (its release and + // the next press can share a frame). + let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_))); + if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) { + view.press_cancelled = false; + } + if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + // A text selection being dragged goes with its drag: the markup, redaction or Replace + // Text that its end would make is not made. + if view.selection_drag { + view.clear_selection(); + } + view.cancel_pointer_gestures(); + view.press_cancelled = true; + } +} + fn draw_pages( ui: &mut egui::Ui, viewport: Rect, @@ -1833,6 +2013,13 @@ fn draw_pages( // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), // or a press they hold. The page modules read it rather than egui's raw pointer state. let ptr = PagePointer::read(ui, &resp, modal); + cancel_on_escape(ui.ctx(), view, &ptr); + view.router.settle(ptr.any_down, !ptr.edges.is_empty()); + // A new press is no longer the last one's, wherever it lands (the page it is on gives it + // its owner; in the gap between pages it has none). + if ptr.pressed().is_some() { + view.router.cancel(); + } let pointer = ptr.pos; // A press on a page makes it the current one (as in Acrobat), so a page command with // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page @@ -2040,7 +2227,8 @@ fn draw_pages( ui.separator(); } } - canvas_action = comments::context_menu(ui, view, info, prefs, allowed, can_modify).or(copy_image); + canvas_action = + comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify).or(copy_image); }); *out = PageOut { canvas_action, @@ -3032,6 +3220,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { visible_pages: &visible_pages, y_shift, fill_text_size: app.fill_text_size, + fill_text_color: app.fill_text_color, today, }; let mut page_out = PageOut::default(); diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index b48027ae35..2472366672 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -459,6 +459,14 @@ pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { app.apply_edit(Edit::FontSizeAnnotation { page, index: annotation, size: app.fill_text_size }); } } + if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) { + ui.menu_button(tl!("Colour"), |ui| { + if let Some(color) = comments::swatch_grid(ui, Some(app.fill_text_color)) { + app.fill_text_color = color; + ui.close(); + } + }); + } if let QuickTool::Comment(tool) = app.quick_tool { let (r, _) = ui.allocate_exact_size(vec2(32.0, 9.0), Sense::hover()); ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider)); diff --git a/crates/ui-egui/src/canvas/crop.rs b/crates/ui-egui/src/canvas/crop.rs index dd88db1a19..bdd1e9e135 100644 --- a/crates/ui-egui/src/canvas/crop.rs +++ b/crates/ui-egui/src/canvas/crop.rs @@ -38,15 +38,18 @@ pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool { if !allowed { return false; } - if resp.drag_started() + // The tool's own press, of the primary button: nothing drawn over the page here holds it. + if resp.drag_started_by(egui::PointerButton::Primary) + && view.router.owner(page).is_none() && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.crop_drag = Some((page, o)); } if let Some((p, start)) = view.crop_drag && p == page - && let Some(end) = pointer { + // Where the pointer is, or where it went down when it is off the window. + let end = pointer.unwrap_or(start); let end = Pos2::new(end.x.clamp(xf.rect.left(), xf.rect.right()), end.y.clamp(xf.rect.top(), xf.rect.bottom())); let r = Rect::from_two_pos(start, end); // Dim what will be cut away. @@ -60,7 +63,7 @@ pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool { painter.rect_filled(band, CornerRadius::ZERO, Color32::from_black_alpha(70)); } painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_rgb(0x14, 0x73, 0xE6)), egui::StrokeKind::Middle); - if resp.drag_stopped() { + if resp.drag_stopped() || !ptr.primary_down { view.crop_drag = None; if r.width() >= 4.0 && r.height() >= 4.0 { let (a, b) = (to_user(xf, info, page, r.left_top()), to_user(xf, info, page, r.right_bottom())); diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 46468a112d..731b2c2586 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -369,10 +369,14 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.rotate_view(false); } zoom_shortcuts(view, &pressed); - if pressed(cmd(Key::OpenBracket)) { + // Alt+← / Alt+→ (⌥ on macOS) too, as in Acrobat and browsers, back to where a link was + // followed from (#3632). Consumed here, before ← / → turn the page. egui matches the pattern + // logically, so an extra Shift is ignored and Shift+Alt+← goes back as well (harmless). + let alt = |k| KeyboardShortcut::new(Modifiers::ALT, k); + if pressed(cmd(Key::OpenBracket)) || pressed(alt(Key::ArrowLeft)) { view.view_history(false); } - if pressed(cmd(Key::CloseBracket)) { + 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)); diff --git a/crates/ui-egui/src/canvas/page_cursor.rs b/crates/ui-egui/src/canvas/page_cursor.rs index 382f5efb63..e40dc1c4f3 100644 --- a/crates/ui-egui/src/canvas/page_cursor.rs +++ b/crates/ui-egui/src/canvas/page_cursor.rs @@ -20,18 +20,19 @@ use crate::QuickTool; pub(crate) enum Layer { /// The page under the active tool: the crosshair it places or draws with, its I-beam or hand. ToolArea, - /// The page's text, to select, and Edit text's paragraphs. + /// The page's text, to select; Edit text's paragraphs, and its pictures beneath them. Text, /// Links. Link, - /// Comments: notes, shapes and markup. + /// Text markup (highlights, underlines…), which lies on the text: under a form field. Comment, /// Form fields. Field, - /// What a drag moves whole: signatures and Fill & Sign's marks, added text and images, - /// selected objects, pictures in Edit text. + /// What a drag moves whole: notes, shapes, signatures and Fill & Sign's marks, added text and + /// pictures, the objects selected together, Prepare's fields. Placed, - /// The grips of the selected item: resize, rewrap and crop handles, paragraph edges. + /// The grips of the selected item: resize, rewrap, rotate and crop handles, paragraph edges, + /// the frame of the paragraph open for typing. Handle, /// An open editor: a paragraph being typed in, a picture being cropped. Editor, @@ -45,9 +46,10 @@ const LAYERS: [Layer; 9] = impl Layer { /// Whether a press with `tool` acts on what lies on this layer, so that the layer's claims - /// count. Only the Select tool follows links (the Link tool edits them) and picks up - /// comments: every other tool draws, places or selects text over them. The other layers are - /// claimed only by the modules of the tools that act on them, so they count for every tool. + /// count. Only the Select tool follows links (the Link tool edits them) and selects text + /// markup: every other tool draws, places or selects text over them. The other layers are + /// claimed only by the modules of the tools that act on them (each module's hit test filters + /// by tool), so they count for every tool. pub(crate) fn reached_by(self, tool: QuickTool) -> bool { match self { Layer::Link => matches!(tool, QuickTool::Select | QuickTool::Link), diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs new file mode 100644 index 0000000000..06d3c48568 --- /dev/null +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -0,0 +1,197 @@ +//! Who a press on the pages belongs to. +//! +//! Each page module answers "what of mine is under this point?" in one place, its hit test, and +//! the answer is a [`Hit`]: what is there ([`Owner`]), on which [`Layer`], with which cursor and +//! tip. The pointer's hits set the cursor and the tip; on the frame of a press, the hits at the +//! press point decide who captures it ([`PageRouter::press`]), by the same table: the highest layer +//! the active tool reaches. Until the button is up only the owner acts on the press, its drag and +//! its release, so what the cursor promised is what the press does, and nothing takes a gesture +//! back halfway. + +use egui::CursorIcon; + +use crate::QuickTool; +use crate::page_cursor::Layer; + +/// What a hit is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Owner { + /// A comment (or a Fill & Sign mark), by its `/Annots` index, and the part of it. + Comment { index: usize, part: CommentPart }, + /// A link, by its index in the document's links. + Link { index: usize }, + /// A form field's widget, by the field's index in the form and the widget's in the field. + Field { field: usize, widget: usize }, + /// Prepare Form: a field's widget, to select and move. + PrepareField { field: usize, widget: usize }, + /// Prepare Form: a corner handle of the selected field, to resize it. + PrepareCorner { left: bool, top: bool }, + /// Edit text: a paragraph, by its index among the page's, and what of it a press takes. + Paragraph { block: usize, part: ParagraphPart }, + /// Edit text: a picture, by its index among the page's, and what of it a press takes. + Picture { index: usize, part: PicturePart }, + /// Edit text: the objects selected together, or the frame of the paragraph open for typing. + Objects { part: ObjectsPart }, + /// Added text or a picture placed on the page, by its index among the page's, and a corner of + /// the selected one (left, top) or the item itself. + Added { index: usize, corner: Option<(bool, bool)> }, +} + +/// The part of a picture a hit is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PicturePart { + /// The picture: a click selects it, a drag moves the selected one. + Body, + /// A corner of the selected picture (left top, right top, left bottom, right bottom): a drag + /// resizes it. + Corner(usize), + /// The selected picture's rotate knob: a drag turns it. + Knob, +} + +/// What of the objects a hit is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ObjectsPart { + /// The objects selected together: a drag moves them all. + Selection, + /// The frame drawn around the paragraph open for typing: a click selects the box, a drag moves it. + Frame, +} + +/// The part of a paragraph a hit is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ParagraphPart { + /// The paragraph: a drag moves it, a click opens it for typing. + Body, + /// Its left or right edge (on screen): a drag rewraps it. + Left, + Right, +} + +/// The part of a comment a hit is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CommentPart { + /// The comment itself: a drag moves it, a click selects it. + Body, + /// A resize handle of the selected comment: (-1, 0 or 1) across, then down. + Handle(i8, i8), + /// A point of the selected line, polygon or cloud. + Point(usize), +} + +/// What lies under a point, for one page module. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Hit { + pub owner: Owner, + pub layer: Layer, + pub icon: CursorIcon, + /// Beside the pointer where this hit wins (a link's target). + pub tip: Option, + /// A drag from here is not this owner's: a click on a highlight selects it, a drag across it + /// selects the text underneath. + pub declines_drag: bool, +} + +impl Hit { + pub(crate) fn new(owner: Owner, layer: Layer, icon: CursorIcon) -> Self { + Hit { owner, layer, icon, tip: None, declines_drag: false } + } +} + +/// The hit that wins among `hits` with `tool`: the highest layer the tool reaches, the last of +/// equals (modules are asked bottom up, in paint order). +pub(crate) fn top(hits: impl IntoIterator, tool: QuickTool) -> Option { + hits.into_iter().filter(|h| h.layer.reached_by(tool)).fold(None, |best: Option, h| match best { + Some(b) if b.layer > h.layer => Some(b), + _ => Some(h), + }) +} + +/// A press the pages hold, and who it belongs to. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Capture { + pub page: usize, + pub owner: Owner, + pub declines_drag: bool, +} + +/// The press the pages hold, from its frame until the button is up. +#[derive(Debug, Default)] +pub(crate) struct PageRouter { + capture: Option, +} + +impl PageRouter { + /// A press on `page`: `hits` at the press point decide who it belongs to. A press nobody's + /// hit claims is held by nobody (the page's text, or a tool that acts anywhere on the page). + pub(crate) fn press(&mut self, page: usize, hits: impl IntoIterator, tool: QuickTool) { + self.capture = top(hits, tool).map(|h| Capture { page, owner: h.owner, declines_drag: h.declines_drag }); + } + + /// Who holds the press on `page`. + pub(crate) fn owner(&self, page: usize) -> Option { + self.capture.as_ref().filter(|c| c.page == page).map(|c| c.owner) + } + + /// Whether the press is held by someone who keeps its drag: then the page's text is not + /// selected by it. + pub(crate) fn keeps_drag(&self) -> bool { + self.capture.as_ref().is_some_and(|c| !c.declines_drag) + } + + /// The press is over once the button is up and its release has been seen. + pub(crate) fn settle(&mut self, any_down: bool, edges: bool) { + if !any_down && !edges { + self.capture = None; + } + } + + /// Drops the press (Escape, a dialog, automation's cancel). + pub(crate) fn cancel(&mut self) { + self.capture = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hit(layer: Layer, index: usize) -> Hit { + Hit::new(Owner::Comment { index, part: CommentPart::Body }, layer, CursorIcon::Move) + } + + #[test] + fn the_highest_reached_layer_wins_and_the_last_of_equals() { + let hits = [hit(Layer::Comment, 0), hit(Layer::Link, 1), hit(Layer::Comment, 2)]; + assert_eq!(top(hits.clone(), QuickTool::Select).map(|h| h.owner), Some(hits[2].owner)); + // A stamp reaches neither links nor comments. + let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved); + assert_eq!(top(hits, stamp), None); + assert_eq!(top([hit(Layer::Placed, 3), hit(Layer::Comment, 4)], stamp).map(|h| h.owner), Some(hit(Layer::Placed, 3).owner)); + } + + #[test] + fn a_press_is_held_until_the_button_is_up_and_its_release_seen() { + let mut r = PageRouter::default(); + r.press(2, [hit(Layer::Comment, 7)], QuickTool::Select); + assert_eq!(r.owner(2), Some(hit(Layer::Comment, 7).owner)); + assert_eq!(r.owner(1), None, "only on its page"); + assert!(r.keeps_drag()); + r.settle(false, true); + assert!(r.owner(2).is_some(), "the release frame still belongs to the press"); + r.settle(false, false); + assert_eq!(r.owner(2), None); + r.press(0, [], QuickTool::Select); + assert_eq!(r.owner(0), None, "a press nobody claims is held by nobody"); + assert!(!r.keeps_drag()); + } + + #[test] + fn a_highlight_lets_its_drag_go_to_the_text() { + let mut r = PageRouter::default(); + r.press(0, [Hit { declines_drag: true, ..hit(Layer::Comment, 0) }], QuickTool::Select); + assert!(r.owner(0).is_some() && !r.keeps_drag()); + r.cancel(); + assert_eq!(r.owner(0), None); + } +} diff --git a/crates/ui-egui/src/canvas/selection.rs b/crates/ui-egui/src/canvas/selection.rs index cfc5967bcf..c2b0545df6 100644 --- a/crates/ui-egui/src/canvas/selection.rs +++ b/crates/ui-egui/src/canvas/selection.rs @@ -93,13 +93,17 @@ impl DocView { Some(s.text(t)).filter(|x| !x.is_empty()) } - /// Select all: every page in Organize, or every word on the current page. + /// Select every page without navigating; shared by Organize and the Pages sidebar. + pub fn select_all_pages(&mut self) -> bool { + self.selected = (0..self.page_count).collect(); + self.select_anchor = (self.page_count > 0).then_some(self.current); + !self.selected.is_empty() + } + + /// Select all words on the current page, or every page in Organize. pub fn select_all(&mut self) -> bool { if self.organize { - self.selected = (0..self.page_count).collect(); - // Keep the current page as the anchor for the next Shift-click. - self.select_anchor = (self.page_count > 0).then_some(self.current); - return !self.selected.is_empty(); + return self.select_all_pages(); } // Scrolling continuously, Select All takes the whole document's text (#2639); one page at // a time, the page shown. diff --git a/crates/ui-egui/src/canvas/signature_drag.rs b/crates/ui-egui/src/canvas/signature_drag.rs index e574d68ec4..c706b8a113 100644 --- a/crates/ui-egui/src/canvas/signature_drag.rs +++ b/crates/ui-egui/src/canvas/signature_drag.rs @@ -1,5 +1,6 @@ //! Cached page and stamp layers: a stamp (an image signature, a Fill & Sign mark, any other stamp) -//! follows each pointer frame, moved or resized, without editing the PDF. +//! follows each pointer frame, moved or resized, without editing the PDF. Any other comment +//! follows the pointer while it is moved: its own layer is drawn where it goes. use egui::{ColorImage, TextureHandle, TextureOptions, pos2, vec2}; use pdfcraft_engine::{Document, Edit}; @@ -16,6 +17,20 @@ pub(crate) struct SignatureDrag { key: Option<(u64, usize, usize)>, aspect_ratio: Option, layers: Option, + /// The comment being moved that has no stamp layers: (edit generation, page, index), and + /// the comment alone, drawn over the page where it goes. + moving: Option<((u64, usize, usize), Moving)>, + /// The comment being moved whose layer couldn't be made (nothing to draw, or a document that + /// can't be read): only its frame follows the pointer, and the layer isn't made again on + /// every frame of the drag. + no_layer: Option<(u64, usize, usize)>, +} + +struct Moving { + /// The comment's layer: `from` is its box when the move started. + layer: StampLayer, + /// Released and applied: keep it where it went until the page shows it there. + settling: bool, } struct Layers { @@ -49,6 +64,8 @@ struct StampLayer { tag: Option, /// The rendered stamp: its tag, and the view-space box its pixels cover. pixels: Option<(u64, TextureHandle, [f32; 4])>, + /// The tag whose render failed: not waited for, and asked for again only at another scale. + failed: Option, } impl SignatureDrag { @@ -75,7 +92,7 @@ impl SignatureDrag { let front = Front::Image { texture, opacity: preview.opacity, turn: preview.turn }; self.layers = Some(Layers::new(front, preview.renderer())); } else if let Ok(Some(preview)) = doc.stamp_preview(page, index) { - let stamp = StampLayer { renderer: preview.stamp_renderer(), from: a.rect, tag: None, pixels: None }; + let stamp = StampLayer { renderer: preview.stamp_renderer(), from: a.rect, tag: None, pixels: None, failed: None }; self.layers = Some(Layers::new(Front::Stamp(Box::new(stamp)), preview.background_renderer())); } } @@ -109,29 +126,14 @@ impl SignatureDrag { let mut pending = layers.received_tag != Some(tag); // The stamp itself is small: it stays sharp at every zoom. if let Front::Stamp(stamp) = &mut layers.front { - let Some((scale, tile)) = stamp_tile(p, stamp.from, scale) else { + match stamp.update(ctx, page, p, scale) { + Some(waiting) => pending |= waiting, // Nothing to lay over the page: the frame alone follows the pointer, as before. - self.layers = None; - return; - }; - let tag = (scale * 1000.0) as u64; - if stamp.tag != Some(tag) { - stamp.renderer.set_queue(vec![RenderRequest { page, scale, tag, tile: Some(tile), ..Default::default() }]); - stamp.tag = Some(tag); - } - if let Some(out) = stamp.renderer.try_recv() - && out.request.tag == tag - { - let (Some(t), None) = (out.request.tile, &out.error) else { + None => { self.layers = None; return; - }; - let s = out.request.scale; - let area = [t.x as f32 / s, t.y as f32 / s, t.x.saturating_add(t.w) as f32 / s, t.y.saturating_add(t.h) as f32 / s]; - let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); - stamp.pixels = Some((tag, ctx.load_texture("signature-drag-stamp", image, TextureOptions::LINEAR), area)); + } } - pending |= stamp.pixels.as_ref().is_none_or(|(received, ..)| *received != tag); } if pending { ctx.request_repaint_after(std::time::Duration::from_millis(16)); @@ -150,6 +152,14 @@ impl SignatureDrag { /// Movement/resizing only change this stamp's geometry, so its layers are reusable. pub fn committed(&mut self, edit: &Edit, generation: u64) { + if let Some((key, m)) = self.moving.as_mut() + && matches!(edit, Edit::MoveAnnotation { page, index, .. } if (*page, *index) == (key.1, key.2)) + { + key.0 = generation; + m.settling = true; + } else { + self.moving = None; + } if let Edit::MoveAnnotation { page, index, .. } | Edit::ResizeAnnotation { page, index, .. } = edit && self.contains(*page, *index) { @@ -158,7 +168,7 @@ impl SignatureDrag { layers.settling = true; } } else { - *self = Self::default(); + *self = Self { moving: self.moving.take(), ..Self::default() }; } } @@ -199,26 +209,133 @@ impl SignatureDrag { *turn, egui::Color32::from_white_alpha((opacity * 255.0).round() as u8), ), - Front::Stamp(stamp) => { - let Some((_, texture, area)) = &stamp.pixels else { return }; - // Where the stamp's pixels were rendered, stretched as its box is from `from` to `rect`. - let from = cx.xf.user_rect(cx.info, page, stamp.from); - if from.width() < 0.01 || from.height() < 0.01 { - return; + Front::Stamp(stamp) => stamp.paint(&painter, cx, page, rect), + } + } + + /// The page of the comment being moved, whose raster [`Self::prepare_move`] asks about. + pub fn moving_page(&self) -> Option { + self.moving.as_ref().map(|((_, page, _), _)| *page) + } + + /// While a comment without stamp layers is moved, its own layer: rendered when the move + /// starts, kept after release until the page's raster shows it where it went (`stale`: the + /// page is still drawn as before its last change). + pub fn prepare_move(&mut self, ctx: &egui::Context, doc: &Document, cv: &CommentView, pending: Option<&Edit>, stale: bool, scale: f32) { + let generation = doc.edit_generation(); + let dragged = match cv.gesture { + Some(Gesture::Move { page, index, .. }) if !self.contains(page, index) => Some((generation, page, index)), + _ => None, + }; + let keep = |key: (u64, usize, usize), m: &Moving| { + let (_, page, index) = key; + dragged == Some(key) + || matches!(pending, Some(Edit::MoveAnnotation { page: p, index: i, .. }) if (*p, *i) == (page, index)) + || m.settling && stale + }; + if self.moving.as_ref().is_some_and(|(key, m)| !keep(*key, m)) { + self.moving = None; + } + if self.no_layer != dragged { + self.no_layer = None; + } + if self.moving.is_none() + && self.no_layer.is_none() + && let Some(key @ (_, page, index)) = dragged + && let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && !a.locked) + { + // Made once per drag: it copies and writes out the whole document. + match doc.comment_layer(page, &[index]) { + Ok(Some(alone)) => { + // The box it is drawn in: a comment kept upright on a turned page (NoRotate) + // isn't drawn in its /Rect, and the frame it follows is that box too. + let rotation = doc.info.pages.get(page).map_or(0, |p| p.rotation); + let layer = StampLayer { renderer: alone.renderer(), from: a.shown_rect(rotation), tag: None, pixels: None, failed: None }; + self.moving = Some((key, Moving { layer, settling: false })); } - let stretch = vec2(rect.width() / from.width(), rect.height() / from.height()); - let mut mesh = egui::Mesh::with_texture(texture.id()); - for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] { - let (x, y) = (area[0] + u * (area[2] - area[0]), area[1] + v * (area[3] - area[1])); - let at = cx.xf.norm_to_screen(x / cx.xf.pw, y / cx.xf.ph); - let pos = rect.min + (at - from.min) * stretch; - mesh.vertices.push(egui::epaint::Vertex { pos, uv: pos2(u, v), color: egui::Color32::WHITE }); - } - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); - painter.add(egui::Shape::mesh(mesh)); + _ => self.no_layer = Some(key), } } + let Some(((_, page, _), m)) = self.moving.as_mut() else { return }; + let Some(p) = doc.info.pages.get(*page) else { return }; + match m.layer.update(ctx, *page, p, scale) { + Some(pending) => { + if pending { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + // Nothing to lay over the page at this scale (no box, or the render failed): the + // frame alone follows the pointer, as before. The layer stays, so another scale + // renders it again without making it anew. + None => m.layer.pixels = None, + } + } + + /// The comment being moved, drawn where it goes (as far as its frame has gone). + pub fn paint_move(&self, painter: &egui::Painter, cx: &PageCx<'_>, cv: &CommentView, pending: Option<&Edit>) { + let Some(((_, page, index), m)) = &self.moving else { return }; + if *page != cx.page || cx.hidden { + return; + } + let Some(a) = cx.get(*index) else { return }; + let shift = painter.ctx().input(|i| i.modifiers.shift); + // As far as its frame has gone from where the move started (once applied, its box is + // already where it went). + let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), cx.ptr.pos, shift, pending); + let from = cx.xf.user_rect(cx.info, *page, m.layer.from); + let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect)); + m.layer.paint(&painter, cx, *page, from.translate(rect.min - from.min)); + } +} + +impl StampLayer { + /// Render the layer's tile at `scale` (or less, within [`MAX_STAMP_SIDE`]) and take it when + /// it arrives. `Some(true)` while the pixels at this scale are still on their way, `None` + /// when there is nothing to draw. + fn update(&mut self, ctx: &egui::Context, page: usize, p: &PageInfo, scale: f32) -> Option { + let (scale, tile) = stamp_tile(p, self.from, scale)?; + let tag = (scale * 1000.0) as u64; + if self.tag != Some(tag) { + self.renderer.set_queue(vec![RenderRequest { page, scale, tag, tile: Some(tile), ..Default::default() }]); + self.tag = Some(tag); + } + if self.failed == Some(tag) { + return None; + } + if let Some(out) = self.renderer.try_recv() + && out.request.tag == tag + { + let (Some(t), None) = (out.request.tile, &out.error) else { + self.failed = Some(tag); + return None; + }; + let s = out.request.scale; + let area = [t.x as f32 / s, t.y as f32 / s, t.x.saturating_add(t.w) as f32 / s, t.y.saturating_add(t.h) as f32 / s]; + let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); + self.pixels = Some((tag, ctx.load_texture("signature-drag-stamp", image, TextureOptions::LINEAR), area)); + } + Some(self.pixels.as_ref().is_none_or(|(received, ..)| *received != tag)) + } + + /// The layer's pixels where its box goes on screen, `rect`: moved, and stretched as its box + /// is from `from` to `rect`. + fn paint(&self, painter: &egui::Painter, cx: &PageCx<'_>, page: usize, rect: egui::Rect) { + let Some((_, texture, area)) = &self.pixels else { return }; + let from = cx.xf.user_rect(cx.info, page, self.from); + if from.width() < 0.01 || from.height() < 0.01 { + return; + } + let stretch = vec2(rect.width() / from.width(), rect.height() / from.height()); + let mut mesh = egui::Mesh::with_texture(texture.id()); + for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] { + let (x, y) = (area[0] + u * (area[2] - area[0]), area[1] + v * (area[3] - area[1])); + let at = cx.xf.norm_to_screen(x / cx.xf.pw, y / cx.xf.ph); + let pos = rect.min + (at - from.min) * stretch; + mesh.vertices.push(egui::epaint::Vertex { pos, uv: pos2(u, v), color: egui::Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(mesh)); } } @@ -244,3 +361,58 @@ fn stamp_tile(p: &PageInfo, rect: [f32; 4], scale: f32) -> Option<(f32, Tile)> { let (right, bottom) = (((x1 * scale).ceil() + 1.0).min(width).max(left + 1.0), ((y1 * scale).ceil() + 1.0).min(height).max(top + 1.0)); Some((scale, Tile { x: left as u32, y: top as u32, w: (right - left) as u32, h: (bottom - top) as u32 })) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + /// One page with one comment that has nothing to draw: a sound annotation without an + /// appearance, for which none is built. + fn silent() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Sound /Rect [50 50 70 70] /Contents (hush) >>", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out + } + + /// A moved comment whose own layer can't be made is asked for once per drag, not on every + /// pointer frame (making it copies and writes out the whole document); only its frame follows + /// the pointer. Once the drag ends, the next one asks again. + #[test] + fn a_comment_with_nothing_to_draw_is_prepared_once_per_drag() { + let mut session = pdfcraft_engine::Session::new(); + let id = session.open("silent.pdf", None, Arc::new(silent()), None).expect("opens"); + let doc = session.get(id).expect("open"); + let index = doc.info.annotations.iter().find(|a| a.subtype == "Sound").expect("listed").index; + let ctx = egui::Context::default(); + let mut cv = CommentView::default(); + cv.gesture = Some(Gesture::Move { page: 0, index, from: pos2(0.0, 0.0) }); + let mut drag = SignatureDrag::default(); + let key = Some((doc.edit_generation(), 0, index)); + drag.prepare_move(&ctx, doc, &cv, None, false, 1.0); + assert!(drag.moving.is_none(), "nothing to draw"); + assert_eq!(drag.no_layer, key, "remembered for this drag"); + drag.prepare_move(&ctx, doc, &cv, None, false, 1.0); + assert!(drag.moving.is_none() && drag.no_layer == key, "and not made again"); + cv.gesture = None; + drag.prepare_move(&ctx, doc, &cv, None, false, 1.0); + assert_eq!(drag.no_layer, None, "forgotten when the drag ends"); + } +} diff --git a/crates/ui-egui/src/canvas/zoom_snap.rs b/crates/ui-egui/src/canvas/zoom_snap.rs index 8e530ac58e..5a0dcf6733 100644 --- a/crates/ui-egui/src/canvas/zoom_snap.rs +++ b/crates/ui-egui/src/canvas/zoom_snap.rs @@ -19,19 +19,22 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - if resp.drag_started() + // The tool's own press, of the primary button: nothing drawn over the page here holds it. + if resp.drag_started_by(egui::PointerButton::Primary) + && view.router.owner(page).is_none() && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.marquee = Some((page, o)); } if let Some((p, start)) = view.marquee && p == page - && let Some(end) = pointer { + // Where the pointer is, or where it went down when it is off the window. + let end = pointer.unwrap_or(start); let end = Pos2::new(end.x.clamp(xf.rect.left(), xf.rect.right()), end.y.clamp(xf.rect.top(), xf.rect.bottom())); let r = Rect::from_two_pos(start, end); ui.painter().rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_rgb(0x14, 0x73, 0xE6)), egui::StrokeKind::Middle); - if resp.drag_stopped() { + if resp.drag_stopped() || !ptr.primary_down { view.marquee = None; let (a, b) = (xf.screen_to_view(r.left_top()), xf.screen_to_view(r.right_bottom())); view.marquee_done = Some((page, r, [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)])); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index d8c51b0e97..08d2040e23 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -83,7 +83,11 @@ impl PdfCraftApp { if self.combine_showing() && matches!(spec.needs, commands::Needs::Undo | commands::Needs::Redo) { return self.combine_can_undo(spec.needs == commands::Needs::Undo); } - commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) + // Undo also takes back typing in a box open on the page, and Redo what Undo took there, + // with no document step. + (commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) + || spec.needs == commands::Needs::Undo && self.page_typing_unapplied() + || spec.needs == commands::Needs::Redo && self.open_box_can_redo()) && (spec.id != "edit.copy" || self .active @@ -489,6 +493,7 @@ impl PdfCraftApp { K::Background }; self.marks_draft.replace = id.ends_with(".update"); + self.marks_draft.opened(kind); self.marks_draft.preview = None; self.dialog = Some(Dialog::Marks(kind)); } @@ -797,7 +802,16 @@ impl PdfCraftApp { if matches!(spec.id, "view.previous_view" | "view.next_view") && !self.command_enabled(spec) { continue; } - if typing && !spec.in_text { + // A box typed into on the page (a paragraph being edited, added text) keeps Undo and + // Redo only while it holds typing the page doesn't have, or Redo has typing to put + // back; then they reach the document. ⌘⇧Z is also ⌘Z to the Undo shortcut, so a + // Redo the box keeps is never taken as Undo. + let page_box = matches!(spec.id, "edit.undo" | "edit.redo") + && self.page_box_focused(ctx) + && !self.page_typing_unapplied() + && !(spec.id == "edit.redo" && self.page_box_can_redo(ctx)) + && !(spec.id == "edit.undo" && ctx.input(|i| i.modifiers.shift)); + if typing && !spec.in_text && !page_box { continue; } let Some(key) = Key::from_name(s.key) else { continue }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e57299fdcb..0e4316445a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1463,6 +1463,13 @@ fn dialog_shortcuts(ui: &mut egui::Ui) -> bool { let pair = |a, b| format!("{} / {}", key(a), key(b)); // Key names, translated where a catalog has them (not scanned as UI literals). let named = |k: &str| tl!(k).to_string(); + // Alt+← / Alt+→ (`canvas::shortcuts`): not a registered command, which has no Alt. + let alt_arrows = if crate::commands::mac_shortcuts(ui.ctx()) { + "⌥← / ⌥→".to_owned() + } else { + let alt = crate::i18n::key_name("Alt"); + format!("{alt}+← / {alt}+→") + }; let scroll = crate::i18n::fmt(tl!("Zoom in / out (also pinch or {key}-scroll)"), &[("key", crate::commands::command_modifier_label(ui.ctx()))]); for (k, v) in [ (pair(FIND_NEXT, FIND_PREV), tl!("Next / previous match").to_string()), @@ -1476,6 +1483,7 @@ fn dialog_shortcuts(ui: &mut egui::Ui) -> bool { (pair(ROTATE_CW, ROTATE_CCW), tl!("Rotate view").to_string()), (format!("{} / {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End")), tl!("First / last page").to_string()), (format!("← / →, {}", pair(PAGE_PREV, PAGE_NEXT)), tl!("Previous / next page").to_string()), + (alt_arrows, format!("{} / {}", tl!("Previous view"), tl!("Next view"))), (named("V"), tl!("Select (V)").to_string()), (named("H / Space (hold)"), tl!("Hand (H)").to_string()), (crate::i18n::key_name("Delete").to_string(), tl!("Delete selected pages (Organize)").to_string()), diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index 92af6279a2..085c581119 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet}; use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2}; -use pdfcraft_engine::{Background, Edit, FontFamily, HeaderFooter, MarkKind, Watermark}; +use pdfcraft_engine::{Backdrop, Background, DocId, Edit, FontFamily, HeaderFooter, MarkKind, Watermark}; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; @@ -77,6 +77,122 @@ impl PageRange { } } +/// How many pages a frame the Background dialog checks for a backdrop of their own, so a long +/// document never stalls the dialog. +const BACKDROP_PAGES_PER_FRAME: usize = 24; + +/// Which pages paint their own backdrop (it hides a background), for the document and edit +/// generation they were checked in. Filled a few pages a frame. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BackdropCheck { + key: Option<(DocId, u64)>, + /// Per page: `None` until checked. + pages: Vec>>, +} + +/// What the check found among the pages a background goes on. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BackdropCount { + /// Pages (0-based, ascending) with a white backdrop that can be replaced. + pub fills: Vec, + /// Pages (0-based, ascending) covered by a picture (scans): the background stays hidden there. + pub images: Vec, + /// Pages not checked yet. + pub pending: usize, +} + +impl BackdropCheck { + /// Check up to [`BACKDROP_PAGES_PER_FRAME`] more of `pages` (0-based) and sort them. + pub fn update(&mut self, doc: &pdfcraft_engine::Document, pages: &[usize]) -> BackdropCount { + let key = (doc.id, doc.edit_generation()); + let count = doc.info.pages.len(); + if self.key != Some(key) || self.pages.len() != count { + self.key = Some(key); + self.pages = vec![None; count]; + } + // One batch a frame: finding a page walks the page tree, so the batch shares one walk. + let mut batch = Vec::with_capacity(BACKDROP_PAGES_PER_FRAME); + for &p in pages { + if batch.len() >= BACKDROP_PAGES_PER_FRAME { + break; + } + if matches!(self.pages.get(p), Some(None)) && !batch.contains(&p) { + batch.push(p); + } + } + if !batch.is_empty() { + for (p, found) in batch.iter().zip(doc.page_backdrops(&batch)) { + if let Some(slot) = self.pages.get_mut(*p) { + *slot = Some(found); + } + } + } + let mut n = BackdropCount::default(); + for &p in pages { + let Some(slot) = self.pages.get(p) else { continue }; + match slot { + None => n.pending += 1, + Some(Some(Backdrop::Fill)) => n.fills.push(p), + Some(Some(Backdrop::Image)) => n.images.push(p), + Some(None) => {} + } + } + for list in [&mut n.fills, &mut n.images] { + list.sort_unstable(); + list.dedup(); + } + n + } + + /// What the check found on `page` (0-based) of this version of `doc`: `None` until it has + /// looked, `Some(None)` for a page with no backdrop of its own. + pub fn found(&self, doc: &pdfcraft_engine::Document, page: usize) -> Option> { + if self.key != Some((doc.id, doc.edit_generation())) { + return None; + } + self.pages.get(page).copied().flatten() + } +} + +impl MarksDraft { + /// The dialog for `kind` is opening. Other settings carry over from last time, but replacing + /// a page's own backdrop starts on: Update restores backdrops before it re-applies, so a + /// page whose backdrop was replaced shows none and the checkbox stays hidden, and a choice + /// left off from an earlier dialog would hide the background again unseen. + pub fn opened(&mut self, kind: MarkKind) { + if kind == MarkKind::Background { + self.bg.replace_backdrop = true; + } + } +} + +/// How many runs of pages a message lists before it stops with "…". +const LISTED_PAGE_RUNS: usize = 8; + +/// After Remove background: the pages (0-based, ascending) whose own white backdrop stays off, +/// because they changed after the background was added (`pdfcraft_engine::EditNotice`). +pub(crate) fn backdrops_left_off_message(pages: &[usize]) -> String { + pages_phrase( + pages, + tl!("Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper."), + tl!("Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper."), + ) +} + +/// `one` or `many` with `{pages}` replaced by `pages` (0-based, ascending) as 1-based runs +/// ("1-3, 6"), so the message says which pages it means. +fn pages_phrase(pages: &[usize], one: &str, many: &str) -> String { + // `count` 0 never matches a list that isn't empty, so the list is always written out. + let runs = crate::combine_ui::range_of(pages, 0); + let mut parts: Vec<&str> = runs.split(", ").take(LISTED_PAGE_RUNS + 1).collect(); + if parts.len() > LISTED_PAGE_RUNS { + parts.truncate(LISTED_PAGE_RUNS); + parts.push("…"); + } + let template = if pages.len() == 1 { one } else { many }; + crate::i18n::fmt(template, &[("pages", &parts.join(", "))]) +} + #[derive(Clone, Debug, PartialEq)] pub struct MarksDraft { pub hf: HeaderFooter, @@ -95,6 +211,8 @@ pub struct MarksDraft { pub use_file: bool, pub file: Option<(String, std::sync::Arc>)>, pub file_page: usize, + /// Background: pages that paint their own backdrop. + pub backdrops: BackdropCheck, /// The page (0-based) the preview was stepped to; `None` previews the current page. Reset /// each time the dialog opens. pub preview: Option, @@ -115,6 +233,7 @@ impl Default for MarksDraft { use_file: false, file: None, file_page: 1, + backdrops: BackdropCheck::default(), preview: None, } } @@ -185,6 +304,19 @@ fn behind(color: [f64; 3], opacity: f64) -> Color32 { rgb32(color.map(|c| 1.0 - a * (1.0 - c))) } +/// The preview's tint for a background colour on a page whose own backdrop is `found` (`None` +/// when it has none, or the check hasn't reached it): no colour where the page will still hide +/// the background after OK, because a picture covers it (a scan) or it keeps its white backdrop +/// with Replace unticked. +fn background_tint(d: &MarksDraft, found: Option) -> Color32 { + let hidden = match found { + Some(Backdrop::Image) => true, + Some(Backdrop::Fill) => !d.bg.replace_backdrop, + None => false, + }; + if hidden { Color32::WHITE } else { behind(d.bg.color, d.bg.opacity) } +} + /// In Update, the pages previewed without the marks the dialog replaces. Their thumbnails still /// have them, so the new marks would be drawn over the old ones, and a new background colour /// would tint the old one. The document without them is made once per dialog (it costs about a @@ -488,6 +620,9 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M d.bg.opacity = pct / 100.0; } }); + if let Some(doc) = app.session.get(id) { + backdrop_options(ui, t, d, doc, count); + } } } ui.add_space(8.0); @@ -507,7 +642,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M view.thumb_id(target) }) }); - let tint = if kind == MarkKind::Background && !d.use_file { behind(d.bg.color, d.bg.opacity) } else { Color32::WHITE }; + let tint = + if kind == MarkKind::Background && !d.use_file { background_tint(d, d.backdrops.found(doc, target).flatten()) } else { Color32::WHITE }; let hf = d.hf.clone(); let size_h = page.1.max(1.0); let (wm, fit) = (d.wm.clone(), d.fit); @@ -700,6 +836,35 @@ fn file_source(ui: &mut egui::Ui, d: &mut MarksDraft, kind: MarkKind) -> bool { browse } +/// Pages that paint their own backdrop would hide the background: offer to replace a white one, +/// and say where a picture (a scan) will still hide it. +fn backdrop_options(ui: &mut egui::Ui, t: &Tokens, d: &mut MarksDraft, doc: &pdfcraft_engine::Document, count: usize) { + let n = d.backdrops.update(doc, &d.range.pages(count)); + if n.pending > 0 { + ui.label(egui::RichText::new(tl!("Checking pages…")).small().color(t.text_faint)); + ui.ctx().request_repaint(); + } + if !n.fills.is_empty() { + ui.add_space(4.0); + let label = pages_phrase( + &n.fills, + tl!("Page {pages} paints its own white backdrop, which would hide the background. Replace it"), + tl!("Pages {pages} paint their own white backdrop, which would hide the background. Replace it"), + ); + ui.add(widgets::checkbox(&mut d.bg.replace_backdrop, &label)); + ui.label(egui::RichText::new(tl!("Removing the background puts the page's own backdrop back.")).small().color(t.text_faint)); + } + if !n.images.is_empty() { + ui.add_space(4.0); + let text = pages_phrase( + &n.images, + tl!("A picture covers page {pages} (a scan?), so the background won't show there."), + tl!("Pictures cover pages {pages} (scans?), so the background won't show there."), + ); + ui.label(egui::RichText::new(text).small().color(crate::content_ui::UNDRAWABLE)); + } +} + /// The edit the dialog's OK makes. pub fn edit(d: &MarksDraft, kind: MarkKind, count: usize) -> Edit { let pages = d.range.pages(count); @@ -734,6 +899,18 @@ mod tests { assert_eq!(undrawable(MarkKind::Background, &d), None); } + #[test] + fn backdrop_messages_name_their_pages() { + let phrase = |pages: &[usize]| pages_phrase(pages, "Page {pages}", "Pages {pages}"); + assert_eq!(phrase(&[2]), "Page 3"); + assert_eq!(phrase(&[0, 1, 2, 5]), "Pages 1-3, 6"); + assert_eq!(phrase(&[0, 1]), "Pages 1-2", "a whole document is still listed"); + let every_other: Vec = (0..40).step_by(2).collect(); + assert_eq!(phrase(&every_other), "Pages 1, 3, 5, 7, 9, 11, 13, 15, …", "a long list stops"); + let runs: Vec = (0..LISTED_PAGE_RUNS * 2).step_by(2).collect(); + assert_eq!(phrase(&runs), "Pages 1, 3, 5, 7, 9, 11, 13, 15", "exactly the limit: no …"); + } + #[test] fn page_ranges_with_subsets() { let r = PageRange::default(); @@ -804,6 +981,19 @@ mod tests { assert_eq!(behind([0.2, 0.4, 0.6], f64::NAN), behind([0.2, 0.4, 0.6], 1.0), "opaque, as the engine has it"); } + #[test] + fn the_preview_shows_a_background_only_where_ok_lets_it_show() { + let mut d = MarksDraft::default(); + let colour = behind(d.bg.color, d.bg.opacity); + assert_ne!(colour, Color32::WHITE); + assert_eq!(background_tint(&d, None), colour, "no backdrop of its own"); + assert_eq!(background_tint(&d, Some(Backdrop::Fill)), colour, "its white backdrop is replaced"); + assert_eq!(background_tint(&d, Some(Backdrop::Image)), Color32::WHITE, "a picture covers it"); + d.bg.replace_backdrop = false; + assert_eq!(background_tint(&d, Some(Backdrop::Fill)), Color32::WHITE, "its white backdrop stays and hides it"); + assert_eq!(background_tint(&d, None), colour, "unticked changes nothing without a backdrop"); + } + #[test] fn the_watermark_edit_carries_the_offset() { let mut d = MarksDraft::default(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index ab6d49f421..6dde2dfcd4 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -27,8 +27,15 @@ 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()) } -/// Keeps the pages (or comment) the user acted on selected, where they now are. -fn select_after_edit(view: &mut crate::DocView, edit: Edit, info: &pdfcraft_render::DocInfo, quick_tool: crate::QuickTool) { +/// Keeps the pages (or comment) the user acted on selected, where they now are. `annots_before` +/// is a deleted annotation's page's annotations before the delete (empty for other edits). +fn select_after_edit( + view: &mut crate::DocView, + edit: Edit, + info: &pdfcraft_render::DocInfo, + annots_before: &[pdfcraft_render::Annotation], + quick_tool: crate::QuickTool, +) { match edit { Edit::MovePages { pages, to } => { let n = pages.iter().collect::>().len(); @@ -64,7 +71,7 @@ fn select_after_edit(view: &mut crate::DocView, edit: Edit, info: &pdfcraft_rend view.comments.open_editor(a.page, n, String::new()); } } - Edit::DeleteAnnotation { .. } => view.comments.selected = None, + Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (page, index)), _ => {} } } @@ -110,6 +117,12 @@ impl PdfCraftApp { } else { (None, 0, false) }; + // A deletion moves the comments after it on the page: these say where the selected one, + // and the one open in the panel's editor, went. + let annots_before = match (&edit, self.session.get(id)) { + (Edit::DeleteAnnotation { page, .. }, Some(d)) => d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect(), + _ => Vec::new(), + }; match self.session.apply(id, edit.clone()) { Ok(()) => { let Some(doc) = self.session.get(id) else { return true }; @@ -153,14 +166,18 @@ impl PdfCraftApp { view.comments.comment_added(); } // Keep the pages the user acted on selected, where they now are. - select_after_edit(view, edit, info, self.quick_tool); + select_after_edit(view, edit, info, &annots_before, self.quick_tool); if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned { self.quick_tool = crate::QuickTool::Select; } - // Text saved as typed but drawn with `?` (its font can't draw it): said, not silent. - // Before the scripts' output, so an alert they raise still shows. + // Text saved as typed but drawn with `?` (its font can't draw it), or pages whose + // own backdrop stays off: said, not silent. Before the scripts' output, so an alert + // they raise still shows. if let Some(n) = self.session.get(id).and_then(|d| d.edit_notices.first()).cloned() { - self.notify(crate::content_ui::undrawn_message(&n)); + self.notify(match &n { + pdfcraft_engine::EditNotice::Undrawn(t) => crate::content_ui::undrawn_message(t), + pdfcraft_engine::EditNotice::BackdropsLeftOff { pages } => crate::marks_ui::backdrops_left_off_message(pages), + }); } let out = self.session.take_js_output(id); self.handle_js(id, out); @@ -198,28 +215,77 @@ impl PdfCraftApp { } if self.combine_showing() { self.combine_history_step(true); - } else { + } else if !self.undo_in_page_box() { self.history_step(true); } } + /// Undo from the menu or toolbar while a box on the page holds typing the page doesn't have + /// yet: the box takes the step, as it does for the key, one step of its typing at a time. + /// Applying the typing first was a new edit, which dropped the document's Redo, and then the + /// typing was taken back all at once. True when the box took the step. + fn undo_in_page_box(&mut self) -> bool { + let Some(ctx) = self.ctx.clone() else { return false }; + let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; + let doc = view.id; + if let Some(ed) = view.line_editor.as_mut().filter(|ed| ed.has_unsaved_text()) { + ed.claim_typing(&ctx, doc); + let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, true) else { return false }; + ed.text = text; + return true; + } + if let Some(d) = view.content.draft.as_mut().filter(|d| d.has_unapplied_typing()) { + d.claim_typing(&ctx, doc); + let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, true) else { return false }; + d.text = text; + return true; + } + false + } + pub fn redo(&mut self) { if self.combine_showing() { self.combine_history_step(false); - } else { + } else if !self.redo_in_page_box() { self.history_step(false); } } + /// Redo from the menu or toolbar while a box on the page has typing of its own: the step is + /// the box's, as the key's is. What Undo took back in the box comes back; typing the page + /// doesn't have yet stays as it is, since applying it first is a new edit, which drops the + /// document's Redo. True when the box took the step. + fn redo_in_page_box(&mut self) -> bool { + let Some(ctx) = self.ctx.clone() else { return false }; + let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; + let doc = view.id; + if let Some(ed) = view.line_editor.as_mut() { + ed.claim_typing(&ctx, doc); + let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, false) else { return ed.has_unsaved_text() }; + ed.text = text; + return true; + } + if let Some(d) = view.content.draft.as_mut() { + d.claim_typing(&ctx, doc); + let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, false) else { return d.has_unapplied_typing() }; + d.text = text; + return true; + } + false + } + fn history_step(&mut self, undo: bool) { let Some((i, id)) = self.active_ids() else { return }; + if !self.close_text_editors(i, id) { + return; + } let result = if undo { self.session.undo(id) } else { self.session.redo(id) }; match result { Ok(label) => { if let Some(doc) = self.session.get(id) { self.views[i].document_changed(&doc.info); } - self.views[i].comments.selected = None; + self.views[i].comments.history_stepped(); let label = crate::i18n::edit_label(&label); if undo { self.notify_fmt("Undid {label}", &[("label", &label)]); @@ -231,6 +297,90 @@ impl PdfCraftApp { } } + /// Before Undo or Redo: a paragraph or added text box open on the page shows the document as + /// it is before the step, so it is applied as clicking away applies it, then closed. Left + /// open, it kept showing what the step took away, and wrote from it when it was next applied. + /// What was typed is then the step Undo takes back. False (nothing is undone, and the box + /// stays open) while it holds text the standard fonts can't draw, or text that couldn't be + /// applied: it is shown, never lost. + pub(crate) fn close_text_editors(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool { + let Some(view) = self.views.get_mut(i) else { return true }; + if let Some(c) = view.content.blocked() { + view.content.hold_blocked(); + self.notify(crate::content_ui::undrawable_message(c)); + return false; + } + if let Some(ed) = view.line_editor.take() + && let Some(edit) = crate::edit_text_ui::finished_edit(ed.clone()) + && !self.apply_edit(edit) + { + if let Some(view) = self.views.get_mut(i) { + view.line_editor = Some(ed); + } + return false; + } + let added = self.session.get(id).map(|d| d.added().to_vec()).unwrap_or_default(); + let Some(view) = self.views.get_mut(i) else { return true }; + // Finishing new text also asks for it to be selected once it exists: put that back too. + let (draft, select_added) = (view.content.draft.clone(), view.content.select_added); + if let Some(edit) = view.content.finish_draft(&added) + && !self.apply_edit(edit) + { + if let Some(view) = self.views.get_mut(i) { + (view.content.draft, view.content.select_added) = (draft, select_added); + } + return false; + } + true + } + + /// Whether the keyboard is in a box typed into on the page: a paragraph being edited, or added + /// text. + pub(crate) fn page_box_focused(&self, ctx: &egui::Context) -> bool { + let focused = ctx.memory(|m| m.focused()); + focused == Some(crate::edit_text_ui::input_id()) || focused == Some(crate::content_ui::input_id()) + } + + /// Whether the focused box on the page has typing its own Redo would put back: after Undo in + /// the box restores what the page has, Redo still belongs to the box, not the document. The + /// same test egui makes when it handles the key. + pub(crate) fn page_box_can_redo(&self, ctx: &egui::Context) -> bool { + let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false }; + let focused = ctx.memory(|m| m.focused()); + let (id, text) = if focused == Some(crate::edit_text_ui::input_id()) + && let Some(ed) = &view.line_editor + { + (crate::edit_text_ui::input_id(), &ed.text) + } else if focused == Some(crate::content_ui::input_id()) + && let Some(d) = &view.content.draft + { + (crate::content_ui::input_id(), &d.text) + } else { + return false; + }; + has_typing_to_redo(ctx, id, text) + } + + /// Whether the box open on the page has a Redo of its own, the step + /// [`Self::redo_in_page_box`] takes for Redo from the menu or toolbar: focus has moved to the + /// menu by then, so unlike [`Self::page_box_can_redo`] this doesn't ask for it. + pub(crate) fn open_box_can_redo(&self) -> bool { + let Some(ctx) = &self.ctx else { return false }; + let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false }; + if let Some(ed) = &view.line_editor { + return has_typing_to_redo(ctx, crate::edit_text_ui::input_id(), &ed.text); + } + view.content.draft.as_ref().is_some_and(|d| has_typing_to_redo(ctx, crate::content_ui::input_id(), &d.text)) + } + + /// Typing in a box on the page (a paragraph being edited, added text) that the page doesn't + /// have yet: Undo and Redo keys take it back in the box first. + pub(crate) fn page_typing_unapplied(&self) -> bool { + self.active.and_then(|i| self.views.get(i)).is_some_and(|v| { + v.line_editor.as_ref().is_some_and(|ed| ed.has_unsaved_text()) || v.content.draft.as_ref().is_some_and(|d| d.has_unapplied_typing()) + }) + } + /// Apply any edit a view queued this frame (organize toolbar, keys). /// Returns false when a field refused the typing queued for it (its editor is open again). pub(crate) fn process_pending_edits(&mut self) -> bool { @@ -579,7 +729,7 @@ impl PdfCraftApp { } let author = self.comment_prefs.author.clone(); let rotation = self.session.get(id).map_or(0, |doc| crate::comments::page_rotation(&doc.info, tb.page)); - let edit = crate::fill_sign::typed(tb.page, tb.at, &text, tb.size, rotation, &author); + let edit = crate::fill_sign::typed_with_color(tb.page, tb.at, &text, tb.size, rotation, &author, tb.color); if self.apply_edit(edit) { if let Some(view) = self.views.get_mut(i) { view.fill_text = None; @@ -747,6 +897,26 @@ fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) -> if rest.is_empty() { Some(item) } else { bookmark_at(&item.children, rest) } } +/// Whether the page text box `id`, showing `text`, has typing its own Redo would put back: the +/// test egui makes when it handles the key. +fn has_typing_to_redo(ctx: &egui::Context, id: egui::Id, text: &str) -> bool { + egui::TextEdit::load_state(ctx, id) + .is_some_and(|state| state.cursor.char_range().is_some_and(|range| state.undoer().has_redo(&(range, text.to_owned())))) +} + +/// The box's own Undo (or Redo), as the key does it: the box's text one step back (or forward) in +/// its typing, with the box's history and cursor moved along. None when there is no such step. +fn typing_step(ctx: &egui::Context, id: egui::Id, text: &str, undo: bool) -> Option { + let mut state = egui::TextEdit::load_state(ctx, id)?; + let mut undoer = state.undoer(); + let current = (state.cursor.char_range()?, text.to_owned()); + let (range, stepped) = if undo { undoer.undo(¤t) } else { undoer.redo(¤t) }?.clone(); + state.set_undoer(undoer); + state.cursor.set_char_range(Some(range)); + egui::TextEdit::store_state(ctx, id, state); + Some(stepped) +} + #[cfg(test)] mod tests; @@ -860,7 +1030,15 @@ mod revert_draft_tests { fn text_typed_into_a_new_text_box_counts_as_unsaved_work() { use crate::content_ui::{TextDraft, default_style}; let mut app = app(); - let draft = |text: &str| TextDraft { page: 0, index: None, rect: [0.0; 4], text: text.into(), style: default_style(), focus: true }; + let draft = |text: &str| TextDraft { + page: 0, + index: None, + rect: [0.0; 4], + text: text.into(), + style: default_style(), + focus: true, + typing_from: None, + }; assert!(!app.has_unsaved_work(0)); app.views[0].content.draft = Some(draft(" \n")); assert!(!app.has_unsaved_work(0)); @@ -885,7 +1063,8 @@ mod revert_draft_tests { let mut app = PdfCraftApp::new(); app.open_bytes("source.pdf", Some(path.clone()), bytes).unwrap(); let rect = [72.0, 72.0, 272.0, 92.0]; - app.views[0].content.draft = Some(TextDraft { page: 0, index: None, rect, text: "Typed".into(), style: default_style(), focus: true }); + app.views[0].content.draft = + Some(TextDraft { page: 0, index: None, rect, text: "Typed".into(), style: default_style(), focus: true, typing_from: None }); let ctx = egui::Context::default(); if quit { assert!(!app.request_quit(&ctx)); diff --git a/crates/ui-egui/src/editing/tests.rs b/crates/ui-egui/src/editing/tests.rs index 87de7e8b87..c14c681ef4 100644 --- a/crates/ui-egui/src/editing/tests.rs +++ b/crates/ui-egui/src/editing/tests.rs @@ -296,6 +296,45 @@ fn one_page_pdf() -> Vec { out } +/// `one_page_pdf`, opening without a password, whose permissions refuse changes. +fn no_changes_pdf() -> Vec { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(one_page_pdf())).unwrap(); + let encryption = pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 8, + encrypt_metadata: true, + seed: [4; 32], + }; + doc.set_encryption(&encryption).unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() +} + +/// Undo with a new text box open on a document that refuses changes: the box is put back as +/// it was. Finishing it had asked for the new item to be selected, and that request stayed +/// behind once the add was refused. +#[test] +fn a_refused_text_box_is_put_back_whole() { + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, no_changes_pdf()).unwrap(); + let style = crate::content_ui::default_style(); + app.views[0].content.draft = Some(crate::content_ui::TextDraft { + page: 0, + index: None, + rect: [20.0, 20.0, 120.0, 40.0], + text: "Typed".into(), + style, + focus: false, + typing_from: None, + }); + app.undo(); + let content = &app.views[0].content; + assert_eq!(content.draft.as_ref().map(|d| d.text.as_str()), Some("Typed"), "the refused box stays open"); + assert_eq!(content.select_added, None, "nothing was added to select"); +} + #[test] fn save_commits_the_open_text_box() { let dir = staging_dir("open-text-box"); @@ -305,8 +344,15 @@ fn save_commits_the_open_text_box() { app.open_bytes("a.pdf", None, one_page_pdf()).unwrap(); app.save_override = Some(dest.to_string_lossy().into_owned()); let style = crate::content_ui::default_style(); - app.views[0].content.draft = - Some(crate::content_ui::TextDraft { page: 0, index: None, rect: [20.0, 20.0, 120.0, 40.0], text: "Typed text".into(), style, focus: true }); + app.views[0].content.draft = Some(crate::content_ui::TextDraft { + page: 0, + index: None, + rect: [20.0, 20.0, 120.0, 40.0], + text: "Typed text".into(), + style, + focus: true, + typing_from: None, + }); assert!(app.save_active(SaveTarget::InPlace)); let id = app.views[0].id; diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 3e6d735523..ad84f9fe53 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -597,6 +597,8 @@ Clean Up تنظيف Color Images الصور الملونة Grayscale Images الصور الرمادية + Monochrome Images الصور أحادية اللون + CCITT Group 4 CCITT المجموعة 4 Bicubic downsampling to تقليل العينات بطريقة Bicubic إلى for images above للصور التي تتجاوز Compression الضغط @@ -866,6 +868,14 @@ Applies to 1 page يُطبَّق على صفحة واحدة Applies to {n} pages يُطبَّق على {n} صفحات Replaces the existing one on those pages. يستبدل الموجود على تلك الصفحات. + Checking pages… جارٍ فحص الصفحات… + Page {pages} paints its own white backdrop, which would hide the background. Replace it الصفحة {pages} ترسم تعبئة بيضاء خاصة بها ستُخفي الخلفية. استبدالها + Pages {pages} paint their own white backdrop, which would hide the background. Replace it الصفحات {pages} ترسم تعبئة بيضاء خاصة بها ستُخفي الخلفية. استبدالها + Removing the background puts the page's own backdrop back. إزالة الخلفية تعيد التعبئة البيضاء الخاصة بالصفحة. + A picture covers page {pages} (a scan?), so the background won't show there. تغطي صورة الصفحة {pages} (مسح ضوئي؟)، لذا لن تظهر الخلفية فيها. + Pictures cover pages {pages} (scans?), so the background won't show there. تغطي صور الصفحات {pages} (مسح ضوئي؟)، لذا لن تظهر الخلفية فيها. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. تغيّرت الصفحة {pages} بعد إضافة الخلفية، لذا تبقى تعبئتها البيضاء الخاصة متوقفة. تبدو كما هي على الورق الأبيض. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. تغيّرت الصفحات {pages} بعد إضافة الخلفية، لذا تبقى تعبئتها البيضاء الخاصة متوقفة. تبدو كما هي على الورق الأبيض. PDF or image PDF أو صورة No file chosen لم يُختر ملف Page number رقم الصفحة @@ -2833,3 +2843,6 @@ certificate Version الإصدار Rotate the selected pages clockwise تدوير الصفحات المحددة باتجاه عقارب الساعة Extract the selected pages… استخراج الصفحات المحددة… Delete the selected pages حذف الصفحات المحددة + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply خيارات هذا الرد diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ec0339f18a..213ef5e60c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -259,6 +259,8 @@ Clean Up Почистване Color Images Цветни изображения Grayscale Images Изображения в сива скала + Monochrome Images Монохромни изображения + CCITT Group 4 CCITT група 4 Bicubic downsampling to Бикубично понижаване на резолюцията до for images above за изображения над Compression Компресия @@ -528,6 +530,14 @@ Applies to 1 page Прилага се към 1 страница Applies to {n} pages Прилага се към {n} страници Replaces the existing one on those pages. Заменя съществуващия на тези страници. + Checking pages… Проверка на страниците… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Страница {pages} има собствено бяло запълване, което би скрило фона. Замяна + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Страници {pages} имат собствено бяло запълване, което би скрило фона. Замяна + Removing the background puts the page's own backdrop back. Премахването на фона връща собственото бяло запълване на страницата. + A picture covers page {pages} (a scan?), so the background won't show there. Изображение покрива страница {pages} (сканиране?), затова фонът няма да се вижда там. + Pictures cover pages {pages} (scans?), so the background won't show there. Изображения покриват страници {pages} (сканирания?), затова фонът няма да се вижда там. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Страница {pages} е променена след добавянето на фона, затова собственото ѝ бяло запълване остава изключено. На бяла хартия изглежда по същия начин. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Страници {pages} са променени след добавянето на фона, затова собственото им бяло запълване остава изключено. На бяла хартия изглеждат по същия начин. PDF or image PDF или изображение No file chosen Не е избран файл Page number Номер на страница @@ -2520,3 +2530,6 @@ animation effects Off Изключени Rotate the selected pages clockwise Завъртане на избраните страници по часовниковата стрелка Extract the selected pages… Извличане на избраните страници… Delete the selected pages Изтриване на избраните страници + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Опции за този отговор diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0cacae3c2c..5513234233 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -560,6 +560,8 @@ Clean Up Neteja Color Images Imatges en color Grayscale Images Imatges en escala de grisos + Monochrome Images Imatges monocromes + CCITT Group 4 CCITT grup 4 Bicubic downsampling to Reducció bicúbica de resolució a for images above per a imatges de més de Compression Compressió @@ -825,6 +827,14 @@ Applies to 1 page S'aplica a 1 pàgina Applies to {n} pages S'aplica a {n} pàgines Replaces the existing one on those pages. Substitueix l'existent en aquestes pàgines. + Checking pages… S'estan comprovant les pàgines… + Page {pages} paints its own white backdrop, which would hide the background. Replace it La pàgina {pages} té el seu propi farciment blanc, que amagaria el fons. Substitueix-lo + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Les pàgines {pages} tenen el seu propi farciment blanc, que amagaria el fons. Substitueix-lo + Removing the background puts the page's own backdrop back. En eliminar el fons, es restaura el farciment blanc propi de la pàgina. + A picture covers page {pages} (a scan?), so the background won't show there. Una imatge cobreix la pàgina {pages} (un escaneig?), així que el fons no s'hi veurà. + Pictures cover pages {pages} (scans?), so the background won't show there. Hi ha imatges que cobreixen les pàgines {pages} (escanejos?), així que el fons no s'hi veurà. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. La pàgina {pages} ha canviat després d'afegir el fons, així que el seu propi farciment blanc continua desactivat. Es veu igual sobre paper blanc. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Les pàgines {pages} han canviat després d'afegir el fons, així que el seu propi farciment blanc continua desactivat. Es veuen igual sobre paper blanc. PDF or image PDF o imatge No file chosen Cap fitxer triat Page number Número de pàgina @@ -2818,3 +2828,6 @@ theme Appearance Aspecte Rotate the selected pages clockwise Gira les pàgines seleccionades en sentit horari Extract the selected pages… Extreu les pàgines seleccionades… Delete the selected pages Suprimeix les pàgines seleccionades + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opcions d'aquesta resposta diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 71c0c62869..ff22088974 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -567,6 +567,8 @@ Clean Up پاککردنەوە Color Images وێنە ڕەنگاوڕەنگەکان Grayscale Images وێنە خۆڵەمێشییەکان + Monochrome Images وێنە یەکڕەنگەکان + CCITT Group 4 CCITT گرووپی 4 Bicubic downsampling to کەمکردنەوەی نموونە بە شێوازی Bicubic بۆ for images above بۆ ئەو وێنانەی سەرووی Compression پەستاندن @@ -835,6 +837,14 @@ Applies to 1 page بۆ 1 پەڕە جێبەجێ دەکرێت Applies to {n} pages بۆ {n} پەڕە جێبەجێ دەکرێت Replaces the existing one on those pages. ئەوەی ئێستا هەیە لەسەر ئەو پەڕانە دەگۆڕێت. + Checking pages… پشکنینی پەڕەکان… + Page {pages} paints its own white backdrop, which would hide the background. Replace it پەڕەی {pages} پڕکردنەوەیەکی سپیی خۆی هەیە کە پاشبنەماکە دەشارێتەوە. جێگرتنەوە + Pages {pages} paint their own white backdrop, which would hide the background. Replace it پەڕەکانی {pages} پڕکردنەوەیەکی سپیی خۆیان هەیە کە پاشبنەماکە دەشارێتەوە. جێگرتنەوە + Removing the background puts the page's own backdrop back. لابردنی پاشبنەما پڕکردنەوە سپییەکەی خودی پەڕەکە دەگەڕێنێتەوە. + A picture covers page {pages} (a scan?), so the background won't show there. وێنەیەک پەڕەی {pages} دادەپۆشێت (سکان؟)، بۆیە پاشبنەماکە لەوێ دەرناکەوێت. + Pictures cover pages {pages} (scans?), so the background won't show there. وێنەکان پەڕەکانی {pages} دادەپۆشن (سکان؟)، بۆیە پاشبنەماکە لەوێ دەرناکەوێت. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. پەڕەی {pages} دوای زیادکردنی پاشبنەما گۆڕا، بۆیە پڕکردنەوە سپییەکەی خۆی کوژاوە دەمێنێتەوە. لەسەر کاغەزی سپی هەمان شێوەیە. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. پەڕەکانی {pages} دوای زیادکردنی پاشبنەما گۆڕان، بۆیە پڕکردنەوە سپییەکانی خۆیان کوژاوە دەمێننەوە. لەسەر کاغەزی سپی هەمان شێوەن. PDF or image PDF یان وێنە No file chosen هیچ فایلێک هەڵنەبژێردراوە Page number ژمارەی پەڕە @@ -2749,3 +2759,6 @@ certificate Version وەشان Rotate the selected pages clockwise پەڕە هەڵبژێردراوەکان بە ئاڕاستەی میلی کاتژمێر بسوڕێنە Extract the selected pages… پەڕە هەڵبژێردراوەکان دەربهێنە… Delete the selected pages پەڕە هەڵبژێردراوەکان بسڕەوە + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply هەڵبژاردەکانی ئەم وەڵامە diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ed6d051b57..2d7cc4c0df 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -681,6 +681,8 @@ key Space Mezerník Clean Up Vyčištění Color Images Barevné obrázky Grayscale Images Obrázky ve stupních šedi + Monochrome Images Monochromatické obrázky + CCITT Group 4 CCITT skupina 4 Bicubic downsampling to Bikubické převzorkování na for images above u obrázků nad Compression Komprese @@ -944,6 +946,14 @@ key Space Mezerník Applies to 1 page Týká se 1 stránky Applies to {n} pages Počet dotčených stránek: {n} Replaces the existing one on those pages. Nahradí stávající na těchto stránkách. + Checking pages… Kontrola stránek… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Stránka {pages} má vlastní bílou výplň, která by zakryla pozadí. Nahradit ji + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Stránky {pages} mají vlastní bílou výplň, která by zakryla pozadí. Nahradit ji + Removing the background puts the page's own backdrop back. Odebráním pozadí se vrátí vlastní bílá výplň stránky. + A picture covers page {pages} (a scan?), so the background won't show there. Stránku {pages} pokrývá obrázek (sken?), takže pozadí na ní nebude vidět. + Pictures cover pages {pages} (scans?), so the background won't show there. Stránky {pages} pokrývají obrázky (skeny?), takže pozadí na nich nebude vidět. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Stránka {pages} se po přidání pozadí změnila, proto její vlastní bílá výplň zůstává vypnutá. Na bílém papíře vypadá stejně. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Stránky {pages} se po přidání pozadí změnily, proto jejich vlastní bílá výplň zůstává vypnutá. Na bílém papíře vypadají stejně. PDF or image PDF nebo obrázek No file chosen Není vybrán žádný soubor Page number Číslo stránky @@ -2864,3 +2874,6 @@ certificate Version Verze Rotate the selected pages clockwise Otočit vybrané stránky po směru hodinových ručiček Extract the selected pages… Extrahovat vybrané stránky… Delete the selected pages Odstranit vybrané stránky + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Možnosti této odpovědi diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8586a21d43..d806d2086d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -570,6 +570,8 @@ Clean Up Aufräumen Color Images Farbbilder Grayscale Images Graustufenbilder + Monochrome Images Monochrome Bilder + CCITT Group 4 CCITT Gruppe 4 Bicubic downsampling to Bikubische Neuberechnung auf for images above für Bilder über Compression Komprimierung @@ -844,6 +846,14 @@ Applies to 1 page Gilt für 1 Seite Applies to {n} pages Gilt für {n} Seiten Replaces the existing one on those pages. Ersetzt das Vorhandene auf diesen Seiten. + Checking pages… Seiten werden geprüft… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Seite {pages} hat eine eigene weiße Fläche, die den Hintergrund verdecken würde. Ersetzen + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Die Seiten {pages} haben eine eigene weiße Fläche, die den Hintergrund verdecken würde. Ersetzen + Removing the background puts the page's own backdrop back. Wird der Hintergrund entfernt, kehrt die eigene weiße Fläche der Seite zurück. + A picture covers page {pages} (a scan?), so the background won't show there. Ein Bild bedeckt Seite {pages} (ein Scan?), daher ist der Hintergrund dort nicht zu sehen. + Pictures cover pages {pages} (scans?), so the background won't show there. Bilder bedecken die Seiten {pages} (Scans?), daher ist der Hintergrund dort nicht zu sehen. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Seite {pages} wurde nach dem Hinzufügen des Hintergrunds geändert, daher bleibt ihre eigene weiße Fläche ausgeschaltet. Auf weißem Papier sieht sie gleich aus. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Seiten {pages} wurden nach dem Hinzufügen des Hintergrunds geändert, daher bleiben ihre eigenen weißen Flächen ausgeschaltet. Auf weißem Papier sehen sie gleich aus. PDF or image PDF oder Bild No file chosen Keine Datei ausgewählt Page number Seitenzahl @@ -2558,3 +2568,6 @@ animation effects Off Aus Rotate the selected pages clockwise Ausgewählte Seiten im Uhrzeigersinn drehen Extract the selected pages… Ausgewählte Seiten extrahieren… Delete the selected pages Ausgewählte Seiten löschen + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Optionen für diese Antwort diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e3eac77caa..3ab45f5dd4 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -438,6 +438,14 @@ Applies to 1 page Εφαρμόζεται σε 1 σελίδα Applies to {n} pages Εφαρμόζεται σε {n} σελίδες Replaces the existing one on those pages. Αντικαθιστά το υπάρχον στοιχείο στις συγκεκριμένες σελίδες. + Checking pages… Έλεγχος σελίδων… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Η σελίδα {pages} έχει δική της λευκή γέμιση, που θα έκρυβε το φόντο. Αντικατάσταση + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Οι σελίδες {pages} έχουν δική τους λευκή γέμιση, που θα έκρυβε το φόντο. Αντικατάσταση + Removing the background puts the page's own backdrop back. Η αφαίρεση του φόντου επαναφέρει τη λευκή γέμιση της σελίδας. + A picture covers page {pages} (a scan?), so the background won't show there. Μια εικόνα καλύπτει τη σελίδα {pages} (σάρωση;), οπότε το φόντο δεν θα φαίνεται εκεί. + Pictures cover pages {pages} (scans?), so the background won't show there. Εικόνες καλύπτουν τις σελίδες {pages} (σαρώσεις;), οπότε το φόντο δεν θα φαίνεται εκεί. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Η σελίδα {pages} άλλαξε μετά την προσθήκη του φόντου, οπότε η δική της λευκή γέμιση μένει απενεργοποιημένη. Σε λευκό χαρτί φαίνεται ίδια. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Οι σελίδες {pages} άλλαξαν μετά την προσθήκη του φόντου, οπότε οι δικές τους λευκές γεμίσεις μένουν απενεργοποιημένες. Σε λευκό χαρτί φαίνονται ίδιες. PDF or image PDF ή εικόνα No file chosen Δεν έχει επιλεγεί αρχείο Scale relative to target page Κλίμακα ως προς τη σελίδα προορισμού @@ -1906,6 +1914,8 @@ Clean Up Εκκαθάριση Color Images Έγχρωμες εικόνες Grayscale Images Εικόνες σε κλίμακα του γκρι + Monochrome Images Μονόχρωμες εικόνες + CCITT Group 4 CCITT Ομάδα 4 Bicubic downsampling to Δικυβική μείωση ανάλυσης σε for images above για εικόνες πάνω από ZIP ZIP @@ -2563,3 +2573,6 @@ animation effects Off Ανενεργά Rotate the selected pages clockwise Περιστροφή των επιλεγμένων σελίδων δεξιόστροφα Extract the selected pages… Εξαγωγή των επιλεγμένων σελίδων… Delete the selected pages Διαγραφή των επιλεγμένων σελίδων + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Επιλογές για αυτήν την απάντηση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0d4e505a70..22036e6485 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -593,6 +593,8 @@ Clean Up Limpieza Color Images Imágenes en color Grayscale Images Imágenes en escala de grises + Monochrome Images Imágenes monocromas + CCITT Group 4 CCITT grupo 4 Bicubic downsampling to Reducción de resolución bicúbica a for images above para imágenes por encima de Compression Compresión @@ -860,6 +862,14 @@ Applies to 1 page Se aplica a 1 página Applies to {n} pages Se aplica a {n} páginas Replaces the existing one on those pages. Reemplaza al existente en esas páginas. + Checking pages… Comprobando páginas… + Page {pages} paints its own white backdrop, which would hide the background. Replace it La página {pages} tiene su propio relleno blanco, que ocultaría el fondo. Reemplazarlo + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Las páginas {pages} tienen su propio relleno blanco, que ocultaría el fondo. Reemplazarlo + Removing the background puts the page's own backdrop back. Al quitar el fondo, se restaura el relleno blanco propio de la página. + A picture covers page {pages} (a scan?), so the background won't show there. Una imagen cubre la página {pages} (¿un escaneo?), así que el fondo no se verá ahí. + Pictures cover pages {pages} (scans?), so the background won't show there. Hay imágenes que cubren las páginas {pages} (¿escaneos?), así que el fondo no se verá ahí. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. La página {pages} cambió después de añadir el fondo, así que su propio relleno blanco sigue desactivado. Se ve igual sobre papel blanco. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Las páginas {pages} cambiaron después de añadir el fondo, así que sus propios rellenos blancos siguen desactivados. Se ven igual sobre papel blanco. PDF or image PDF o imagen No file chosen Ningún archivo elegido Page number Número de página @@ -2880,3 +2890,6 @@ theme Appearance Aspecto Rotate the selected pages clockwise Girar las páginas seleccionadas en sentido horario Extract the selected pages… Extraer las páginas seleccionadas… Delete the selected pages Eliminar las páginas seleccionadas + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opciones de esta respuesta diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv index 5cfc217533..6c0b5fe15b 100644 --- a/crates/ui-egui/src/i18n/fa.tsv +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -594,6 +594,8 @@ Clean Up پاک‌سازی Color Images تصاویر رنگی Grayscale Images تصاویر خاکستری + Monochrome Images تصاویر تک‌رنگ + CCITT Group 4 CCITT گروه ۴ Bicubic downsampling to نمونه برداری کاهشی دو مکعبی به for images above برای تصاویر بالا‌تر از Compression فشرده‌سازی @@ -2804,3 +2806,6 @@ theme Appearance ظاهر two cells would both be named {name} دو خانه هر دو با نام {name} نام‌گذاری می‌شوند a field named {name} already exists فیلدی با نام {name} از قبل وجود دارد Today امروز + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply گزینه‌های این پاسخ diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a542ee9b91..1664001ce2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -560,6 +560,8 @@ Clean Up Siivoa Color Images Värikuvat Grayscale Images Harmaasävykuvat + Monochrome Images Mustavalkokuvat + CCITT Group 4 CCITT ryhmä 4 Bicubic downsampling to Bikuubinen alinäytteistys tarkkuuteen for images above kuville, joiden tarkkuus yli Compression Pakkaus @@ -824,6 +826,14 @@ Applies to 1 page Koskee yhtä sivua Applies to {n} pages Koskee {n} sivua Replaces the existing one on those pages. Korvaa näiden sivujen nykyisen. + Checking pages… Tarkistetaan sivuja… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Sivulla {pages} on oma valkoinen täyttö, joka peittäisi taustan. Korvaa se + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Sivuilla {pages} on oma valkoinen täyttö, joka peittäisi taustan. Korvaa ne + Removing the background puts the page's own backdrop back. Taustan poistaminen palauttaa sivun oman valkoisen täytön. + A picture covers page {pages} (a scan?), so the background won't show there. Kuva peittää sivun {pages} (skannaus?), joten tausta ei näy siinä. + Pictures cover pages {pages} (scans?), so the background won't show there. Kuvat peittävät sivut {pages} (skannauksia?), joten tausta ei näy niissä. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Sivu {pages} muuttui taustan lisäämisen jälkeen, joten sen oma valkoinen täyttö pysyy pois päältä. Valkoisella paperilla se näyttää samalta. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Sivut {pages} muuttuivat taustan lisäämisen jälkeen, joten niiden omat valkoiset täytöt pysyvät pois päältä. Valkoisella paperilla ne näyttävät samalta. PDF or image PDF tai kuva No file chosen Tiedostoa ei ole valittu Page number Sivunumero @@ -2827,3 +2837,6 @@ theme Appearance Teema Rotate the selected pages clockwise Kierrä valittuja sivuja myötäpäivään Extract the selected pages… Pura valitut sivut… Delete the selected pages Poista valitut sivut + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Tämän vastauksen asetukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 61c8b5171d..380965efa4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -570,6 +570,8 @@ Clean Up Nettoyer Color Images Images couleur Grayscale Images Images en niveaux de gris + Monochrome Images Images monochromes + CCITT Group 4 CCITT groupe 4 Bicubic downsampling to Sous-échantillonnage bicubique à for images above pour les images au-dessus de Compression Compression @@ -840,6 +842,14 @@ Applies to 1 page S'applique à 1 page Applies to {n} pages S'applique à {n} pages Replaces the existing one on those pages. Remplace celui qui existe déjà sur ces pages. + Checking pages… Vérification des pages… + Page {pages} paints its own white backdrop, which would hide the background. Replace it La page {pages} a son propre remplissage blanc, qui masquerait l'arrière-plan. Le remplacer + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Les pages {pages} ont leur propre remplissage blanc, qui masquerait l'arrière-plan. Le remplacer + Removing the background puts the page's own backdrop back. Supprimer l'arrière-plan rétablit le remplissage blanc propre à la page. + A picture covers page {pages} (a scan?), so the background won't show there. Une image couvre la page {pages} (une numérisation ?), l'arrière-plan n'y sera donc pas visible. + Pictures cover pages {pages} (scans?), so the background won't show there. Des images couvrent les pages {pages} (des numérisations ?), l'arrière-plan n'y sera donc pas visible. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. La page {pages} a changé après l'ajout de l'arrière-plan, son propre remplissage blanc reste donc désactivé. Elle paraît identique sur papier blanc. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Les pages {pages} ont changé après l'ajout de l'arrière-plan, leurs propres remplissages blancs restent donc désactivés. Elles paraissent identiques sur papier blanc. PDF or image PDF ou image No file chosen Aucun fichier choisi Page number Numéro de page @@ -2823,3 +2833,6 @@ certificate Version Version Rotate the selected pages clockwise Faire pivoter les pages sélectionnées dans le sens horaire Extract the selected pages… Extraire les pages sélectionnées… Delete the selected pages Supprimer les pages sélectionnées + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Options de cette réponse diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index e9d627aca9..dabd138494 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -544,6 +544,8 @@ Clean Up सफ़ाई Color Images रंगीन छवियाँ Grayscale Images ग्रेस्केल छवियाँ + Monochrome Images मोनोक्रोम छवियाँ + CCITT Group 4 CCITT समूह 4 Bicubic downsampling to बाइक्यूबिक डाउनसैंपलिंग तक for images above इससे बड़ी छवियों के लिए Compression संपीड़न @@ -808,6 +810,14 @@ Applies to 1 page 1 पृष्ठ पर लागू Applies to {n} pages {n} पृष्ठों पर लागू Replaces the existing one on those pages. उन पृष्ठों पर मौजूदा वाले को बदल देता है। + Checking pages… पृष्ठों की जाँच हो रही है… + Page {pages} paints its own white backdrop, which would hide the background. Replace it पृष्ठ {pages} की अपनी सफ़ेद भराई है, जो पृष्ठभूमि को छिपा देगी। इसे बदलें + Pages {pages} paint their own white backdrop, which would hide the background. Replace it पृष्ठ {pages} की अपनी सफ़ेद भराई है, जो पृष्ठभूमि को छिपा देगी। इसे बदलें + Removing the background puts the page's own backdrop back. पृष्ठभूमि हटाने पर पृष्ठ की अपनी सफ़ेद भराई वापस आ जाती है। + A picture covers page {pages} (a scan?), so the background won't show there. एक छवि पृष्ठ {pages} को ढकती है (स्कैन?), इसलिए वहाँ पृष्ठभूमि नहीं दिखेगी। + Pictures cover pages {pages} (scans?), so the background won't show there. छवियाँ पृष्ठ {pages} को ढकती हैं (स्कैन?), इसलिए वहाँ पृष्ठभूमि नहीं दिखेगी। + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. पृष्ठभूमि जोड़ने के बाद पृष्ठ {pages} बदल गया, इसलिए उसकी अपनी सफ़ेद भराई बंद रहती है। सफ़ेद कागज़ पर यह वैसा ही दिखता है। + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. पृष्ठभूमि जोड़ने के बाद पृष्ठ {pages} बदल गए, इसलिए उनकी अपनी सफ़ेद भराई बंद रहती है। सफ़ेद कागज़ पर वे वैसे ही दिखते हैं। PDF or image पीडीएफ़ या छवि No file chosen कोई फ़ाइल नहीं चुनी गई Page number पृष्ठ संख्या @@ -2794,3 +2804,6 @@ theme Appearance स्वरूप Rotate the selected pages clockwise चयनित पृष्ठों को दक्षिणावर्त घुमाएँ Extract the selected pages… चयनित पृष्ठ निकालें… Delete the selected pages चयनित पृष्ठ हटाएँ + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply इस जवाब के विकल्प diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6b37b9169b..af29ea62ea 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -568,6 +568,8 @@ Clean Up Tisztítás Color Images Színes képek Grayscale Images Szürkeárnyalatos képek + Monochrome Images Monokróm képek + CCITT Group 4 CCITT 4. csoport Bicubic downsampling to Kettős köbös újramintázás erre for images above az ennél nagyobb képeknél Compression Tömörítés @@ -837,6 +839,14 @@ Applies to 1 page 1 oldalra vonatkozik Applies to {n} pages {n} oldalra vonatkozik Replaces the existing one on those pages. Lecseréli a meglévőt ezeken az oldalakon. + Checking pages… Oldalak ellenőrzése… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Ennek az oldalnak saját fehér kitöltése van, amely eltakarná a hátteret: {pages}. Csere + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Ezeknek az oldalaknak saját fehér kitöltése van, amely eltakarná a hátteret: {pages}. Csere + Removing the background puts the page's own backdrop back. A háttér eltávolítása visszaállítja az oldal saját fehér kitöltését. + A picture covers page {pages} (a scan?), so the background won't show there. Egy kép fedi le ezt az oldalt: {pages} (szkennelt?), így ott nem látszik a háttér. + Pictures cover pages {pages} (scans?), so the background won't show there. Képek fedik le ezeket az oldalakat: {pages} (szkenneltek?), így ott nem látszik a háttér. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Ez az oldal megváltozott a háttér hozzáadása után, ezért a saját fehér kitöltése kikapcsolva marad: {pages}. Fehér papíron ugyanúgy néz ki. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Ezek az oldalak megváltoztak a háttér hozzáadása után, ezért a saját fehér kitöltésük kikapcsolva marad: {pages}. Fehér papíron ugyanúgy néznek ki. PDF or image PDF vagy kép No file chosen Nincs fájl kiválasztva Page number Oldalszám @@ -2556,3 +2566,6 @@ animation effects Off Ki Rotate the selected pages clockwise A kijelölt oldalak forgatása az óramutató járásával megegyezően Extract the selected pages… A kijelölt oldalak kibontása… Delete the selected pages A kijelölt oldalak törlése + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply A válasz beállításai diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 292b336e10..f9c1ca59fd 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -946,6 +946,14 @@ Replacement text Teks pengganti Replacement: pages of {name} Pengganti: halaman dari {name} Replaces the existing one on those pages. Menggantikan yang ada di halaman-halaman itu. + Checking pages… Memeriksa halaman… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Halaman {pages} memiliki isian putihnya sendiri, yang akan menutupi latar belakang. Ganti + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Halaman-halaman {pages} memiliki isian putihnya sendiri, yang akan menutupi latar belakang. Ganti + Removing the background puts the page's own backdrop back. Menghapus latar belakang akan mengembalikan isian putih milik halaman. + A picture covers page {pages} (a scan?), so the background won't show there. Sebuah gambar menutupi halaman {pages} (hasil pindai?), jadi latar belakang tidak akan terlihat di sana. + Pictures cover pages {pages} (scans?), so the background won't show there. Gambar menutupi halaman-halaman {pages} (hasil pindai?), jadi latar belakang tidak akan terlihat di sana. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Halaman {pages} berubah setelah latar belakang ditambahkan, jadi isian putihnya sendiri tetap mati. Di kertas putih tampilannya sama. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Halaman {pages} berubah setelah latar belakang ditambahkan, jadi isian putihnya sendiri tetap mati. Di kertas putih tampilannya sama. Report Laporan Report… Laporan… Requires user to enter a password for: Memerlukan pengguna untuk memasukkan kata sandi untuk: @@ -2055,6 +2063,8 @@ comment menu Edit Edit Formulas and similar non-text elements need alternate text. Add alternate text to them. Rumus dan elemen non-teks serupa memerlukan teks alternatif. Tambahkan teks alternatif ke elemen tersebut. Gray Abu-abu Grayscale Images Gambar Skala Abu-abu + Monochrome Images Gambar Monokrom + CCITT Group 4 CCITT Grup 4 Green Hijau Green on black Hijau di atas hitam H / Space (hold) H / Space (tahan) @@ -2795,3 +2805,6 @@ theme Appearance Tampilan Rotate the selected pages clockwise Putar halaman yang dipilih searah jarum jam Extract the selected pages… Ekstrak halaman yang dipilih… Delete the selected pages Hapus halaman yang dipilih + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opsi untuk balasan ini diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 20729bae6a..9a59150ac7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -594,6 +594,8 @@ Clean Up Pulizia Color Images Immagini a colori Grayscale Images Immagini in scala di grigi + Monochrome Images Immagini monocromatiche + CCITT Group 4 CCITT gruppo 4 Bicubic downsampling to Sottocampionamento bicubico a for images above per immagini oltre Compression Compressione @@ -863,6 +865,14 @@ Applies to 1 page Si applica a 1 pagina Applies to {n} pages Si applica a {n} pagine Replaces the existing one on those pages. Sostituisce l'elemento esistente su quelle pagine. + Checking pages… Verifica delle pagine… + Page {pages} paints its own white backdrop, which would hide the background. Replace it La pagina {pages} ha un proprio riempimento bianco, che nasconderebbe lo sfondo. Sostituiscilo + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Le pagine {pages} hanno un proprio riempimento bianco, che nasconderebbe lo sfondo. Sostituiscilo + Removing the background puts the page's own backdrop back. Rimuovendo lo sfondo, il riempimento bianco della pagina viene ripristinato. + A picture covers page {pages} (a scan?), so the background won't show there. Un'immagine copre la pagina {pages} (una scansione?), quindi lì lo sfondo non sarà visibile. + Pictures cover pages {pages} (scans?), so the background won't show there. Delle immagini coprono le pagine {pages} (scansioni?), quindi lì lo sfondo non sarà visibile. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. La pagina {pages} è cambiata dopo l'aggiunta dello sfondo, quindi il suo riempimento bianco resta disattivato. Su carta bianca appare uguale. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Le pagine {pages} sono cambiate dopo l'aggiunta dello sfondo, quindi i loro riempimenti bianchi restano disattivati. Su carta bianca appaiono uguali. PDF or image PDF o immagine No file chosen Nessun file scelto Page number Numero di pagina @@ -2789,3 +2799,6 @@ certificate SHA-256 digest Impronta SHA-256 Rotate the selected pages clockwise Ruota le pagine selezionate in senso orario Extract the selected pages… Estrai le pagine selezionate… Delete the selected pages Elimina le pagine selezionate + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opzioni per questa risposta diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ed4d43b6c..8f99902421 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -597,6 +597,8 @@ Clean Up クリーンアップ Color Images カラー画像 Grayscale Images グレースケール画像 + Monochrome Images 白黒画像 + CCITT Group 4 CCITT グループ 4 Bicubic downsampling to バイキュービック法でダウンサンプル: for images above 対象画像の解像度: Compression 圧縮 @@ -867,6 +869,14 @@ Applies to 1 page 1 ページに適用 Applies to {n} pages {n} ページに適用 Replaces the existing one on those pages. 対象ページの既存の項目を置き換えます。 + Checking pages… ページを確認しています… + Page {pages} paints its own white backdrop, which would hide the background. Replace it {pages} ページには独自の白い塗りがあり、背景が隠れてしまいます。置き換える + Pages {pages} paint their own white backdrop, which would hide the background. Replace it {pages} ページに独自の白い塗りがあり、背景が隠れてしまいます。置き換える + Removing the background puts the page's own backdrop back. 背景を削除すると、ページ独自の白い塗りが元に戻ります。 + A picture covers page {pages} (a scan?), so the background won't show there. {pages} ページは画像(スキャン?)で覆われているため、背景は表示されません。 + Pictures cover pages {pages} (scans?), so the background won't show there. {pages} ページが画像(スキャン?)で覆われているため、そこでは背景は表示されません。 + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. 背景を追加した後に {pages} ページが変更されたため、そのページ独自の白い塗りはオフのままです。白い紙では見た目は変わりません。 + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. 背景を追加した後に {pages} ページが変更されたため、それらのページ独自の白い塗りはオフのままです。白い紙では見た目は変わりません。 PDF or image PDF または画像 No file chosen ファイルが選択されていません Page number ページ番号 @@ -2557,3 +2567,6 @@ animation effects Off オフ Rotate the selected pages clockwise 選択したページを時計回りに回転 Extract the selected pages… 選択したページを抽出… Delete the selected pages 選択したページを削除 + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply この返信のオプション diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 9022c3591c..03093ca045 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -557,6 +557,8 @@ Clean Up Тазалау Color Images Түсті кескіндер Grayscale Images Сұр реңкті кескіндер + Monochrome Images Монохромды кескіндер + CCITT Group 4 CCITT 4-топ Bicubic downsampling to Бикубтық төмендету for images above мұнан асатын кескіндер үшін Compression Сығымдау @@ -822,6 +824,14 @@ Applies to 1 page 1 бетке қолданылады Applies to {n} pages {n} бетке қолданылады Replaces the existing one on those pages. Осы беттерде бар элементті ауыстырады. + Checking pages… Беттер тексерілуде… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Өз ақ толтыруы фонды жасыратын бет: {pages}. Ауыстыру + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Өз ақ толтыруы фонды жасыратын беттер: {pages}. Ауыстыру + Removing the background puts the page's own backdrop back. Фонды жою беттің өз ақ толтыруын қайтарады. + A picture covers page {pages} (a scan?), so the background won't show there. Сурет жауып тұрған бет (сканер ме?): {pages}; онда фон көрінбейді. + Pictures cover pages {pages} (scans?), so the background won't show there. Суреттер жауып тұрған беттер (сканер ме?): {pages}; онда фон көрінбейді. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Фон қосылғаннан кейін өзгерген бет, сондықтан оның өз ақ толтыруы өшірулі қалады: {pages}. Ақ қағазда бірдей көрінеді. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Фон қосылғаннан кейін өзгерген беттер, сондықтан олардың өз ақ толтырулары өшірулі қалады: {pages}. Ақ қағазда бірдей көрінеді. PDF or image PDF немесе сурет No file chosen Файл таңдалмады Page number Бет нөмірі @@ -2555,3 +2565,6 @@ animation effects Off Өшірулі Rotate the selected pages clockwise Таңдалған беттерді сағат тілімен бұру Extract the selected pages… Таңдалған беттерді шығарып алу… Delete the selected pages Таңдалған беттерді жою + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Осы жауаптың параметрлері diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 09b9cad9fa..c66d7b16b1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -544,6 +544,8 @@ Clean Up 정리 Color Images 컬러 이미지 Grayscale Images 회색조 이미지 + Monochrome Images 흑백 이미지 + CCITT Group 4 CCITT 그룹 4 Bicubic downsampling to 다음 값으로 바이큐빅 다운샘플링 for images above 다음 값을 초과하는 이미지에 적용 Compression 압축 @@ -808,6 +810,14 @@ Applies to 1 page 1개 페이지에 적용됩니다 Applies to {n} pages {n}개 페이지에 적용됩니다 Replaces the existing one on those pages. 해당 페이지의 기존 항목을 대체합니다. + Checking pages… 페이지 확인 중… + Page {pages} paints its own white backdrop, which would hide the background. Replace it {pages}페이지에는 배경을 가리는 자체 흰색 채우기가 있습니다. 바꾸기 + Pages {pages} paint their own white backdrop, which would hide the background. Replace it {pages}페이지에 배경을 가리는 자체 흰색 채우기가 있습니다. 바꾸기 + Removing the background puts the page's own backdrop back. 배경을 제거하면 페이지 자체의 흰색 채우기가 복원됩니다. + A picture covers page {pages} (a scan?), so the background won't show there. 이미지가 {pages}페이지를 덮고 있어(스캔본?) 배경이 보이지 않습니다. + Pictures cover pages {pages} (scans?), so the background won't show there. 이미지들이 {pages}페이지를 덮고 있어(스캔본?) 배경이 보이지 않습니다. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. 배경을 추가한 뒤 {pages}페이지가 바뀌어서 자체 흰색 채우기가 꺼진 채로 남습니다. 흰 종이에서는 똑같이 보입니다. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. 배경을 추가한 뒤 {pages}페이지가 바뀌어서 자체 흰색 채우기가 꺼진 채로 남습니다. 흰 종이에서는 똑같이 보입니다. PDF or image PDF 또는 이미지 No file chosen 선택한 파일 없음 Page number 페이지 번호 @@ -2794,3 +2804,6 @@ theme Appearance 모양 Rotate the selected pages clockwise 선택한 페이지를 시계 방향으로 회전 Extract the selected pages… 선택한 페이지 추출… Delete the selected pages 선택한 페이지 삭제 + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply 이 답글의 옵션 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 964467d823..b163feb628 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -565,6 +565,8 @@ Clean Up ອະນາໄມ Color Images ຮູບພາບສີ Grayscale Images ຮູບພາບລະດັບສີເທົາ + Monochrome Images ຮູບພາບຂາວດຳ + CCITT Group 4 CCITT ກຸ່ມ 4 Bicubic downsampling to ຫຼຸດຄວາມລະອຽດແບບ Bicubic ລົງເປັນ: for images above ສຳລັບຮູບພາບທີ່ເກີນ: Compression ການບີບອັດ @@ -834,6 +836,14 @@ Applies to 1 page ນຳໃຊ້ກັບ 1 ໜ້າ Applies to {n} pages ນຳໃຊ້ກັບ {n} ໜ້າ Replaces the existing one on those pages. ປ່ຽນແທນສິ່ງທີ່ມີຢູ່ແລ້ວໃນໜ້າເຫຼົ່ານັ້ນ. + Checking pages… ກຳລັງກວດສອບໜ້າ… + Page {pages} paints its own white backdrop, which would hide the background. Replace it ໜ້າ {pages} ມີພື້ນສີຂາວຂອງມັນເອງ ເຊິ່ງຈະບັງພື້ນຫຼັງ. ປ່ຽນແທນ + Pages {pages} paint their own white backdrop, which would hide the background. Replace it ໜ້າ {pages} ມີພື້ນສີຂາວຂອງພວກມັນເອງ ເຊິ່ງຈະບັງພື້ນຫຼັງ. ປ່ຽນແທນ + Removing the background puts the page's own backdrop back. ການລຶບພື້ນຫຼັງຈະເອົາພື້ນສີຂາວເດີມຂອງໜ້າກັບຄືນມາ. + A picture covers page {pages} (a scan?), so the background won't show there. ມີຮູບພາບປົກໜ້າ {pages} ຢູ່ (ພາບສະແກນ?), ສະນັ້ນພື້ນຫຼັງຈະບໍ່ສະແດງຢູ່ບ່ອນນັ້ນ. + Pictures cover pages {pages} (scans?), so the background won't show there. ມີຮູບພາບຫຼາຍຮູບປົກໜ້າ {pages} ຢູ່ (ພາບສະແກນ?), ສະນັ້ນພື້ນຫຼັງຈະບໍ່ສະແດງຢູ່ບ່ອນນັ້ນ. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. ໜ້າ {pages} ປ່ຽນໄປຫຼັງຈາກເພີ່ມພື້ນຫຼັງ, ສະນັ້ນພື້ນສີຂາວຂອງມັນເອງຍັງປິດຢູ່. ເທິງເຈ້ຍສີຂາວມັນເບິ່ງຄືເກົ່າ. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. ໜ້າ {pages} ປ່ຽນໄປຫຼັງຈາກເພີ່ມພື້ນຫຼັງ, ສະນັ້ນພື້ນສີຂາວຂອງພວກມັນເອງຍັງປິດຢູ່. ເທິງເຈ້ຍສີຂາວພວກມັນເບິ່ງຄືເກົ່າ. PDF or image PDF ຫຼື ຮູບພາບ No file chosen ຍັງບໍ່ໄດ້ເລືອກໄຟລ໌ Page number ໝາຍເລກໜ້າ @@ -2465,3 +2475,6 @@ animation effects Off ປິດ Rotate the selected pages clockwise ໝູນໜ້າທີ່ເລືອກຕາມເຂັມໂມງ Extract the selected pages… ແຍກໜ້າທີ່ເລືອກອອກ… Delete the selected pages ລຶບໜ້າທີ່ເລືອກ + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply ຕົວເລືອກຂອງການຕອບກັບນີ້ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 43749f4ec8..2ede0e846f 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -999,6 +999,14 @@ Replacement text Pakaitinis tekstas Replacement: pages of {name} Pakaitinis dokumentas: {name} puslapiai Replaces the existing one on those pages. Tuose puslapiuose pakeičia esamą. + Checking pages… Tikrinami puslapiai… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Puslapis su savo baltu užpildu, kuris paslėptų foną: {pages}. Pakeisti + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Puslapiai su savo baltu užpildu, kuris paslėptų foną: {pages}. Pakeisti + Removing the background puts the page's own backdrop back. Pašalinus foną, grąžinamas paties puslapio baltas užpildas. + A picture covers page {pages} (a scan?), so the background won't show there. Paveikslėliu padengtas puslapis (nuskaitytas?): {pages}; fonas ten nesimatys. + Pictures cover pages {pages} (scans?), so the background won't show there. Paveikslėliais padengti puslapiai (nuskaityti?): {pages}; fonas ten nesimatys. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Puslapis pasikeitė po fono pridėjimo, todėl jo baltas užpildas lieka išjungtas: {pages}. Ant balto popieriaus jis atrodo taip pat. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Puslapiai pasikeitė po fono pridėjimo, todėl jų balti užpildai lieka išjungti: {pages}. Ant balto popieriaus jie atrodo taip pat. Report Ataskaita Report… Ataskaita… Requires user to enter a password for: Naudotojas turi įvesti slaptažodį, kad galėtų: @@ -2123,6 +2131,8 @@ comment menu Edit Redaguoti Formulas and similar non-text elements need alternate text. Add alternate text to them. Formulėms ir panašiems netekstiniams elementams reikia alternatyviojo teksto. Pridėkite jį. Gray Pilka Grayscale Images Pilkų atspalvių paveikslėliai + Monochrome Images Vienspalviai paveikslėliai + CCITT Group 4 CCITT 4 grupė Green Žalia Green on black Žalia juodame fone H / Space (hold) H / tarpo klavišas (laikyti) @@ -2818,3 +2828,6 @@ theme Appearance Išvaizda Rotate the selected pages clockwise Pasukti pasirinktus puslapius pagal laikrodžio rodyklę Extract the selected pages… Išskleisti pasirinktus puslapius… Delete the selected pages Ištrinti pasirinktus puslapius + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Šio atsakymo parinktys diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 6f1739974a..b96b2b0fc3 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -955,6 +955,14 @@ Replacement text Erstatningstekst Replacement: pages of {name} Erstatning: sider fra {name} Replaces the existing one on those pages. Erstatter den eksisterende på disse sidene. + Checking pages… Kontrollerer sidene… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Side {pages} maler sin egen hvite flate, som ville skjult bakgrunnen. Erstatt den + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Sidene {pages} maler sine egne hvite flater, som ville skjult bakgrunnen. Erstatt dem + Removing the background puts the page's own backdrop back. Når bakgrunnen fjernes, kommer sidens egen hvite flate tilbake. + A picture covers page {pages} (a scan?), so the background won't show there. Et bilde dekker side {pages} (en skanning?), så bakgrunnen vises ikke der. + Pictures cover pages {pages} (scans?), so the background won't show there. Bilder dekker sidene {pages} (skanninger?), så bakgrunnen vises ikke der. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Side {pages} ble endret etter at bakgrunnen ble lagt til, så sidens egen hvite flate forblir slått av. På hvitt papir ser den lik ut. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Sidene {pages} ble endret etter at bakgrunnen ble lagt til, så sidenes egne hvite flater forblir slått av. På hvitt papir ser de like ut. Report Rapport Report… Rapport… Requires user to enter a password for: Krever at brukeren skriver inn et passord for: @@ -2060,6 +2068,8 @@ Formulas and similar non-text elements need alternate text. Add alternate text to them. Formler og lignende elementer som ikke er tekst, trenger alternativ tekst. Legg til alternativ tekst på dem. Gray Grå Grayscale Images Gråtonebilder + Monochrome Images Svart-hvitt-bilder + CCITT Group 4 CCITT gruppe 4 Green Grønn Green on black Grønn på svart H / Space (hold) H / Mellomrom (hold) @@ -2733,3 +2743,6 @@ key Space Mellomrom Rotate the selected pages clockwise Roter de valgte sidene med klokken Extract the selected pages… Trekk ut de valgte sidene… Delete the selected pages Slett de valgte sidene + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Alternativer for dette svaret diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 963d717584..5138118de1 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -592,6 +592,8 @@ Clean Up Porządkowanie Color Images Obrazy kolorowe Grayscale Images Obrazy w skali szarości + Monochrome Images Obrazy monochromatyczne + CCITT Group 4 CCITT grupa 4 Bicubic downsampling to Zmniejsz rozdzielczość metodą dwusześcienną do for images above dla obrazów powyżej Compression Kompresja @@ -858,6 +860,14 @@ Applies to 1 page Dotyczy 1 strony Applies to {n} pages Liczba objętych stron: {n} Replaces the existing one on those pages. Zastępuje istniejący element na tych stronach. + Checking pages… Sprawdzanie stron… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Strona z własnym białym wypełnieniem, które zasłoniłoby tło: {pages}. Zastąp + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Strony z własnym białym wypełnieniem, które zasłoniłoby tło: {pages}. Zastąp + Removing the background puts the page's own backdrop back. Usunięcie tła przywraca własne białe wypełnienie strony. + A picture covers page {pages} (a scan?), so the background won't show there. Strona zakryta obrazem (skan?): {pages}; tło nie będzie tam widoczne. + Pictures cover pages {pages} (scans?), so the background won't show there. Strony zakryte obrazami (skany?): {pages}; tło nie będzie tam widoczne. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Strona zmieniona po dodaniu tła, więc jej własne białe wypełnienie pozostaje wyłączone: {pages}. Na białym papierze wygląda tak samo. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Strony zmienione po dodaniu tła, więc ich własne białe wypełnienia pozostają wyłączone: {pages}. Na białym papierze wyglądają tak samo. PDF or image PDF lub obraz No file chosen Nie wybrano pliku Page number Numer strony @@ -2653,3 +2663,6 @@ animation effects Off Wyłączone Rotate the selected pages clockwise Obróć zaznaczone strony w prawo Extract the selected pages… Wyodrębnij zaznaczone strony… Delete the selected pages Usuń zaznaczone strony + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opcje tej odpowiedzi diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c34ae83bc5..6b48076218 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -216,6 +216,8 @@ Clean Up Limpar Color Images Imagens coloridas Grayscale Images Imagens em escala de cinza + Monochrome Images Imagens monocromáticas + CCITT Group 4 CCITT grupo 4 Bicubic downsampling to Redução bicúbica de amostragem para for images above para imagens acima de Compression Compactação @@ -484,6 +486,14 @@ Applies to 1 page Aplica-se a 1 página Applies to {n} pages Aplica-se a {n} páginas Replaces the existing one on those pages. Substitui o existente nessas páginas. + Checking pages… Verificando páginas… + Page {pages} paints its own white backdrop, which would hide the background. Replace it A página {pages} tem um preenchimento branco próprio, que esconderia o plano de fundo. Substituí-lo + Pages {pages} paint their own white backdrop, which would hide the background. Replace it As páginas {pages} têm um preenchimento branco próprio, que esconderia o plano de fundo. Substituí-lo + Removing the background puts the page's own backdrop back. Remover o plano de fundo restaura o preenchimento branco da página. + A picture covers page {pages} (a scan?), so the background won't show there. Uma imagem cobre a página {pages} (uma digitalização?), então o plano de fundo não aparecerá nela. + Pictures cover pages {pages} (scans?), so the background won't show there. Imagens cobrem as páginas {pages} (digitalizações?), então o plano de fundo não aparecerá nelas. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. A página {pages} mudou depois que o plano de fundo foi adicionado, então seu preenchimento branco próprio continua desligado. Em papel branco ela parece igual. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. As páginas {pages} mudaram depois que o plano de fundo foi adicionado, então seus preenchimentos brancos próprios continuam desligados. Em papel branco elas parecem iguais. PDF or image PDF ou imagem No file chosen Nenhum arquivo escolhido Page number Número da página @@ -2842,3 +2852,6 @@ theme Appearance Aparência Rotate the selected pages clockwise Girar as páginas selecionadas no sentido horário Extract the selected pages… Extrair as páginas selecionadas… Delete the selected pages Excluir as páginas selecionadas + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opções desta resposta diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 78d2298b93..382e44d419 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -955,6 +955,14 @@ Replacement text Text de înlocuire Replacement: pages of {name} Înlocuire: pagini din {name} Replaces the existing one on those pages. Înlocuiește-l pe cel existent pe acele pagini. + Checking pages… Se verifică paginile… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Pagina {pages} are o umplere albă proprie, care ar ascunde fundalul. Înlocuiește-o + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Paginile {pages} au o umplere albă proprie, care ar ascunde fundalul. Înlocuiește-o + Removing the background puts the page's own backdrop back. Eliminarea fundalului readuce umplerea albă proprie a paginii. + A picture covers page {pages} (a scan?), so the background won't show there. O imagine acoperă pagina {pages} (o scanare?), deci fundalul nu se va vedea acolo. + Pictures cover pages {pages} (scans?), so the background won't show there. Imagini acoperă paginile {pages} (scanări?), deci fundalul nu se va vedea acolo. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Pagina {pages} s-a schimbat după adăugarea fundalului, așa că umplerea ei albă proprie rămâne oprită. Pe hârtie albă arată la fel. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Paginile {pages} s-au schimbat după adăugarea fundalului, așa că umplerile lor albe proprii rămân oprite. Pe hârtie albă arată la fel. Report Raport Report… Raport… Requires user to enter a password for: Utilizatorul trebuie să introducă o parolă pentru: @@ -2066,6 +2074,8 @@ comment menu Edit Editare Formulas and similar non-text elements need alternate text. Add alternate text to them. Formulele și alte elemente non-text similare au nevoie de text alternativ. Adăugați-le text alternativ. Gray Gri Grayscale Images Imagini în tonuri de gri + Monochrome Images Imagini monocrome + CCITT Group 4 CCITT grupa 4 Green Verde Green on black Verde pe negru H / Space (hold) H / Spațiu (ținut apăsat) @@ -2818,3 +2828,6 @@ theme Appearance Aspect Rotate the selected pages clockwise Rotește paginile selectate în sens orar Extract the selected pages… Extrage paginile selectate… Delete the selected pages Șterge paginile selectate + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opțiuni pentru acest răspuns diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 274232d586..50b1d71c33 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -590,6 +590,8 @@ Clean Up Очистить Color Images Цветные изображения Grayscale Images Изображения в оттенках серого + Monochrome Images Монохромные изображения + CCITT Group 4 CCITT группа 4 Bicubic downsampling to Бикубическое уменьшение до for images above для изображений выше Compression Сжатие @@ -860,6 +862,14 @@ Applies to 1 page Применяется к 1 странице Applies to {n} pages Применяется к {n} страницам Replaces the existing one on those pages. Заменяет существующий на этих страницах. + Checking pages… Проверка страниц… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Страница с собственной белой заливкой, которая скроет фон: {pages}. Заменить + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Страницы с собственной белой заливкой, которая скроет фон: {pages}. Заменить + Removing the background puts the page's own backdrop back. При удалении фона собственная белая заливка страницы возвращается. + A picture covers page {pages} (a scan?), so the background won't show there. Страница, закрытая изображением (скан?): {pages}; фон там не будет виден. + Pictures cover pages {pages} (scans?), so the background won't show there. Страницы, закрытые изображениями (сканы?): {pages}; фон там не будет виден. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Страница изменена после добавления фона, поэтому её собственная белая заливка остаётся выключенной: {pages}. На белой бумаге она выглядит так же. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Страницы изменены после добавления фона, поэтому их собственные белые заливки остаются выключенными: {pages}. На белой бумаге они выглядят так же. PDF or image PDF или изображение No file chosen Файл не выбран Page number Номер страницы @@ -2470,3 +2480,6 @@ animation effects Off Выкл Rotate the selected pages clockwise Повернуть выбранные страницы по часовой стрелке Extract the selected pages… Извлечь выбранные страницы… Delete the selected pages Удалить выбранные страницы + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Параметры этого ответа diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 420e6a5996..c9c3b13342 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -891,6 +891,8 @@ Grab bag Razno Gray Siva Grayscale Images Sivinske slike + Monochrome Images Enobarvne slike + CCITT Group 4 CCITT skupina 4 Green Zelena H / Space (hold) H / preslednica (drži) HTML web page Spletna stran HTML @@ -1527,6 +1529,14 @@ Replacement text Nadomestno besedilo Replacement: pages of {name} Zamenjava: strani datoteke {name} Replaces the existing one on those pages. Nadomesti obstoječe na teh straneh. + Checking pages… Preverjanje strani… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Stran z lastnim belim polnilom, ki bi skrilo ozadje: {pages}. Zamenjaj + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Strani z lastnim belim polnilom, ki bi skrilo ozadje: {pages}. Zamenjaj + Removing the background puts the page's own backdrop back. Odstranitev ozadja povrne lastno belo polnilo strani. + A picture covers page {pages} (a scan?), so the background won't show there. Stran, ki jo prekriva slika (skeniranje?): {pages}; ozadje tam ne bo vidno. + Pictures cover pages {pages} (scans?), so the background won't show there. Strani, ki jih prekrivajo slike (skeniranja?): {pages}; ozadje tam ne bo vidno. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Stran se je spremenila po dodajanju ozadja, zato njeno lastno belo polnilo ostane izklopljeno: {pages}. Na belem papirju je videti enako. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Strani so se spremenile po dodajanju ozadja, zato njihova lastna bela polnila ostanejo izklopljena: {pages}. Na belem papirju so videti enako. Replacing images arrives on the web with image file pickers Zamenjava slik bo v spletni različici na voljo z izbirniki slikovnih datotek Replacing images on page {p} arrives on the web with image pickers ({i}) Zamenjava slik na strani {p} bo v spletni različici na voljo z izbirniki slik ({i}) Reply Odgovori @@ -2562,3 +2572,6 @@ animation effects Off Izklopljeni Rotate the selected pages clockwise Zavrti izbrane strani v smeri urnega kazalca Extract the selected pages… Izvleci izbrane strani… Delete the selected pages Izbriši izbrane strani + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Možnosti tega odgovora diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index ba7c9dc5b3..b600158593 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -880,6 +880,8 @@ Grab bag Të ndryshme Gray Gri Grayscale Images Imazhe në nuanca gri + Monochrome Images Imazhe bardhezi + CCITT Group 4 CCITT grupi 4 Green Jeshile H / Space (hold) H / Hapësira (mbaje shtypur) HTML web page Faqe interneti HTML @@ -1479,6 +1481,14 @@ Replacement text Teksti zëvendësues Replacement: pages of {name} Zëvendësimi: faqet e {name} Replaces the existing one on those pages. Zëvendëson atë ekzistues në ato faqe. + Checking pages… Po kontrollohen faqet… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Faqja {pages} ka mbushjen e vet të bardhë, e cila do ta fshihte sfondin. Zëvendësoje + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Faqet {pages} kanë mbushjen e tyre të bardhë, e cila do ta fshihte sfondin. Zëvendësoje + Removing the background puts the page's own backdrop back. Heqja e sfondit e rikthen mbushjen e bardhë të faqes. + A picture covers page {pages} (a scan?), so the background won't show there. Një imazh e mbulon faqen {pages} (skanim?), prandaj sfondi nuk do të duket aty. + Pictures cover pages {pages} (scans?), so the background won't show there. Imazhe mbulojnë faqet {pages} (skanime?), prandaj sfondi nuk do të duket aty. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Faqja {pages} ndryshoi pasi u shtua sfondi, ndaj mbushja e saj e bardhë mbetet e fikur. Në letër të bardhë duket njësoj. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Faqet {pages} ndryshuan pasi u shtua sfondi, ndaj mbushjet e tyre të bardha mbeten të fikura. Në letër të bardhë duken njësoj. Replacing images arrives on the web with image file pickers Zëvendësimi i imazheve vjen në ueb me zgjedhësit e skedarëve të imazhit Replacing images on page {p} arrives on the web with image pickers ({i}) Zëvendësimi i imazheve në faqen {p} vjen në ueb me zgjedhësit e imazhit ({i}) Reply Përgjigju @@ -2544,3 +2554,6 @@ animation effects Off Joaktiv Rotate the selected pages clockwise Rrotullo faqet e përzgjedhura në drejtim të orës Extract the selected pages… Nxirr faqet e përzgjedhura… Delete the selected pages Fshi faqet e përzgjedhura + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Opsionet për këtë përgjigje diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 7f339989ee..2de096de01 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -574,6 +574,8 @@ comment menu Edit సవరించు Clean Up చక్కదిద్దడం Color Images రంగు చిత్రాలు Grayscale Images గ్రేస్కేల్ చిత్రాలు + Monochrome Images మోనోక్రోమ్ చిత్రాలు + CCITT Group 4 CCITT గ్రూప్ 4 Bicubic downsampling to బైక్యూబిక్ డౌన్‌శాంప్లింగ్, లక్ష్యం: for images above ఈ రిజల్యూషన్ మించిన చిత్రాలకు: Compression కుదింపు @@ -840,6 +842,14 @@ comment menu Edit సవరించు Applies to 1 page 1 పేజీకి వర్తిస్తుంది Applies to {n} pages {n} పేజీలకు వర్తిస్తుంది Replaces the existing one on those pages. ఆ పేజీలలో ఇప్పటికే ఉన్నదాన్ని భర్తీ చేస్తుంది. + Checking pages… పేజీలను తనిఖీ చేస్తోంది… + Page {pages} paints its own white backdrop, which would hide the background. Replace it పేజీ {pages}కి సొంత తెల్లని నింపు ఉంది, అది నేపథ్యాన్ని దాచేస్తుంది. భర్తీ చేయండి + Pages {pages} paint their own white backdrop, which would hide the background. Replace it పేజీలు {pages}కి సొంత తెల్లని నింపు ఉంది, అది నేపథ్యాన్ని దాచేస్తుంది. భర్తీ చేయండి + Removing the background puts the page's own backdrop back. నేపథ్యాన్ని తీసివేస్తే పేజీ సొంత తెల్లని నింపు తిరిగి వస్తుంది. + A picture covers page {pages} (a scan?), so the background won't show there. పేజీ {pages}ని ఒక చిత్రం కప్పుతోంది (స్కాన్?), కాబట్టి అక్కడ నేపథ్యం కనిపించదు. + Pictures cover pages {pages} (scans?), so the background won't show there. పేజీలు {pages}ని చిత్రాలు కప్పుతున్నాయి (స్కాన్‌లు?), కాబట్టి అక్కడ నేపథ్యం కనిపించదు. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. నేపథ్యాన్ని జోడించిన తర్వాత పేజీ {pages} మారింది, కాబట్టి దాని సొంత తెల్లని నింపు ఆపివేయబడే ఉంటుంది. తెల్ల కాగితంపై అది అలాగే కనిపిస్తుంది. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. నేపథ్యాన్ని జోడించిన తర్వాత పేజీలు {pages} మారాయి, కాబట్టి వాటి సొంత తెల్లని నింపులు ఆపివేయబడే ఉంటాయి. తెల్ల కాగితంపై అవి అలాగే కనిపిస్తాయి. PDF or image PDF లేదా చిత్రం No file chosen ఫైల్ ఏదీ ఎంచుకోలేదు Page number పేజీ సంఖ్య @@ -2466,3 +2476,6 @@ animation effects Off ఆఫ్ Rotate the selected pages clockwise ఎంచుకున్న పేజీలను సవ్య దిశలో తిప్పు Extract the selected pages… ఎంచుకున్న పేజీలను సంగ్రహించు… Delete the selected pages ఎంచుకున్న పేజీలను తొలగించు + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply ఈ ప్రత్యుత్తరం ఎంపికలు diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 2c9b892ed7..8a772ae3e5 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -554,6 +554,8 @@ Clean Up ล้างข้อมูล Color Images รูปภาพสี Grayscale Images รูปภาพระดับสีเทา + Monochrome Images รูปภาพขาวดำ + CCITT Group 4 CCITT กลุ่ม 4 Bicubic downsampling to ลดความละเอียดแบบ Bicubic เป็น for images above สำหรับรูปภาพที่สูงกว่า Compression การบีบอัด @@ -819,6 +821,14 @@ Applies to 1 page มีผลกับ 1 หน้า Applies to {n} pages มีผลกับ {n} หน้า Replaces the existing one on those pages. แทนที่ของเดิมในหน้าเหล่านั้น + Checking pages… กำลังตรวจสอบหน้า… + Page {pages} paints its own white backdrop, which would hide the background. Replace it หน้า {pages} มีพื้นสีขาวของตัวเองซึ่งจะบังพื้นหลัง แทนที่ + Pages {pages} paint their own white backdrop, which would hide the background. Replace it หน้า {pages} มีพื้นสีขาวของตัวเองซึ่งจะบังพื้นหลัง แทนที่ทั้งหมด + Removing the background puts the page's own backdrop back. การเอาพื้นหลังออกจะคืนพื้นสีขาวเดิมของหน้า + A picture covers page {pages} (a scan?), so the background won't show there. มีรูปภาพปิดหน้า {pages} อยู่ (ภาพสแกน?) จึงจะมองไม่เห็นพื้นหลังที่นั่น + Pictures cover pages {pages} (scans?), so the background won't show there. มีรูปภาพหลายรูปปิดหน้า {pages} อยู่ (ภาพสแกน?) จึงจะมองไม่เห็นพื้นหลังที่นั่น + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. หน้า {pages} เปลี่ยนไปหลังจากเพิ่มพื้นหลัง พื้นสีขาวของตัวเองจึงยังปิดอยู่ บนกระดาษสีขาวจะดูเหมือนเดิม + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. หน้า {pages} เปลี่ยนไปหลังจากเพิ่มพื้นหลัง พื้นสีขาวของหน้าเหล่านั้นจึงยังปิดอยู่ บนกระดาษสีขาวจะดูเหมือนเดิม PDF or image PDF หรือรูปภาพ No file chosen ยังไม่ได้เลือกไฟล์ Page number เลขหน้า @@ -2720,3 +2730,6 @@ theme Appearance ลักษณะที่ปรากฏ Rotate the selected pages clockwise หมุนหน้าที่เลือกตามเข็มนาฬิกา Extract the selected pages… แยกหน้าที่เลือกออก… Delete the selected pages ลบหน้าที่เลือก + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply ตัวเลือกของการตอบกลับนี้ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a2ecaea179..84a301987b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -555,6 +555,8 @@ Clean Up Temizle Color Images Renkli Resimler Grayscale Images Gri Tonlamalı Resimler + Monochrome Images Tek Renkli Resimler + CCITT Group 4 CCITT Grup 4 Bicubic downsampling to Şu değere bikübik alt örnekleme: for images above bundan büyük resimler için: Compression Sıkıştırma @@ -821,6 +823,14 @@ Applies to 1 page 1 sayfaya uygulanır Applies to {n} pages {n} sayfaya uygulanır Replaces the existing one on those pages. Bu sayfalardaki mevcut olanın yerini alır. + Checking pages… Sayfalar denetleniyor… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Arka planı gizleyecek kendi beyaz dolgusu olan sayfa: {pages}. Değiştir + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Arka planı gizleyecek kendi beyaz dolgusu olan sayfalar: {pages}. Değiştir + Removing the background puts the page's own backdrop back. Arka planı kaldırmak, sayfanın kendi beyaz dolgusunu geri getirir. + A picture covers page {pages} (a scan?), so the background won't show there. Resimle kaplı sayfa (tarama mı?): {pages}; arka plan orada görünmez. + Pictures cover pages {pages} (scans?), so the background won't show there. Resimlerle kaplı sayfalar (tarama mı?): {pages}; arka plan orada görünmez. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Arka plan eklendikten sonra değişen sayfa, bu yüzden kendi beyaz dolgusu kapalı kalıyor: {pages}. Beyaz kâğıtta aynı görünür. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Arka plan eklendikten sonra değişen sayfalar, bu yüzden kendi beyaz dolguları kapalı kalıyor: {pages}. Beyaz kâğıtta aynı görünürler. PDF or image PDF veya resim No file chosen Dosya seçilmedi Page number Sayfa numarası @@ -2553,3 +2563,6 @@ animation effects Off Kapalı Rotate the selected pages clockwise Seçili sayfaları saat yönünde döndür Extract the selected pages… Seçili sayfaları ayıkla… Delete the selected pages Seçili sayfaları sil + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Bu yanıtın seçenekleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9c76868839..3be7e487cf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -595,6 +595,8 @@ Clean Up Очищення Color Images Кольорові зображення Grayscale Images Зображення у відтінках сірого + Monochrome Images Монохромні зображення + CCITT Group 4 CCITT група 4 Bicubic downsampling to Бікубічне зниження роздільної здатності до for images above для зображень із роздільною здатністю понад Compression Стиснення @@ -864,6 +866,14 @@ Applies to 1 page Застосовується до 1 сторінки Applies to {n} pages Застосовується до {n} сторінок Replaces the existing one on those pages. Замінює наявний елемент на цих сторінках. + Checking pages… Перевірка сторінок… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Сторінка з власною білою заливкою, яка приховає тло: {pages}. Замінити + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Сторінки з власною білою заливкою, яка приховає тло: {pages}. Замінити + Removing the background puts the page's own backdrop back. Вилучення тла повертає власну білу заливку сторінки. + A picture covers page {pages} (a scan?), so the background won't show there. Сторінка, закрита зображенням (скан?): {pages}; тло там не буде видно. + Pictures cover pages {pages} (scans?), so the background won't show there. Сторінки, закриті зображеннями (скани?): {pages}; тло там не буде видно. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Сторінка змінена після додавання тла, тому її власна біла заливка залишається вимкненою: {pages}. На білому папері вона виглядає так само. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Сторінки змінені після додавання тла, тому їхні власні білі заливки залишаються вимкненими: {pages}. На білому папері вони виглядають так само. PDF or image PDF або зображення No file chosen Файл не вибрано Page number Номер сторінки @@ -2579,3 +2589,6 @@ animation effects Off Вимкнено Rotate the selected pages clockwise Повернути вибрані сторінки за годинниковою стрілкою Extract the selected pages… Витягнути вибрані сторінки… Delete the selected pages Видалити вибрані сторінки + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Параметри цієї відповіді diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 973ba870ea..e409ca84ee 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -992,6 +992,14 @@ theme Appearance Giao diện Replacement text Văn bản được thay vào Replacement: pages of {name} Thay thế: các trang của {name} Replaces the existing one on those pages. Thay thế cái hiện có trên các trang đó. + Checking pages… Đang kiểm tra các trang… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Trang {pages} có lớp tô trắng riêng, sẽ che mất nền. Thay thế + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Các trang {pages} có lớp tô trắng riêng, sẽ che mất nền. Thay thế + Removing the background puts the page's own backdrop back. Gỡ nền sẽ khôi phục lớp tô trắng riêng của trang. + A picture covers page {pages} (a scan?), so the background won't show there. Một hình ảnh phủ kín trang {pages} (bản quét?), nên nền sẽ không hiện ở đó. + Pictures cover pages {pages} (scans?), so the background won't show there. Hình ảnh phủ kín các trang {pages} (bản quét?), nên nền sẽ không hiện ở đó. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Trang {pages} đã thay đổi sau khi thêm nền, nên lớp tô trắng riêng của nó vẫn tắt. Trên giấy trắng trông vẫn như cũ. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Các trang {pages} đã thay đổi sau khi thêm nền, nên lớp tô trắng riêng của chúng vẫn tắt. Trên giấy trắng trông vẫn như cũ. Report Báo cáo Report… Báo cáo… Requires user to enter a password for: Yêu cầu người dùng nhập mật khẩu cho: @@ -2111,6 +2119,8 @@ comment menu Edit Sửa Formulas and similar non-text elements need alternate text. Add alternate text to them. Công thức và các phần tử không phải văn bản tương tự cần có văn bản thay thế. Hãy thêm văn bản thay thế cho chúng. Gray Xám Grayscale Images Ảnh thang xám + Monochrome Images Ảnh đơn sắc + CCITT Group 4 CCITT nhóm 4 Green Xanh lá Green on black Xanh lá trên nền đen H / Space (hold) H / Space (giữ) @@ -2824,3 +2834,6 @@ key Space Space Rotate the selected pages clockwise Xoay các trang đã chọn theo chiều kim đồng hồ Extract the selected pages… Trích xuất các trang đã chọn… Delete the selected pages Xóa các trang đã chọn + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Tùy chọn cho câu trả lời này diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d77c8f6425..addc549056 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -265,6 +265,8 @@ Clean Up 清理 Color Images 彩色图像 Grayscale Images 灰度图像 + Monochrome Images 单色图像 + CCITT Group 4 CCITT 第 4 组 Bicubic downsampling to 双三次降采样至 for images above (高于此分辨率的图像) Compression 压缩 @@ -535,6 +537,14 @@ Applies to 1 page 应用于 1 页 Applies to {n} pages 应用于 {n} 页 Replaces the existing one on those pages. 替换这些页面上的现有内容。 + Checking pages… 正在检查页面… + Page {pages} paints its own white backdrop, which would hide the background. Replace it 第 {pages} 页有自己的白色填充,会遮住背景。替换它 + Pages {pages} paint their own white backdrop, which would hide the background. Replace it 第 {pages} 页各有自己的白色填充,会遮住背景。替换它们 + Removing the background puts the page's own backdrop back. 移除背景后,页面自己的白色填充会恢复。 + A picture covers page {pages} (a scan?), so the background won't show there. 第 {pages} 页被一张图片覆盖(扫描件?),因此那里不会显示背景。 + Pictures cover pages {pages} (scans?), so the background won't show there. 第 {pages} 页被图片覆盖(扫描件?),因此那里不会显示背景。 + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. 第 {pages} 页在添加背景后有改动,因此其自身的白色填充保持关闭。在白纸上看起来一样。 + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. 第 {pages} 页在添加背景后有改动,因此它们自身的白色填充保持关闭。在白纸上看起来一样。 PDF or image PDF 或图像 No file chosen 未选择文件 Page number 页码 @@ -2521,3 +2531,6 @@ animation effects Off 关 Rotate the selected pages clockwise 顺时针旋转所选页面 Extract the selected pages… 提取所选页面… Delete the selected pages 删除所选页面 + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply 此回复的选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ce3b939f1d..43436a595d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -221,6 +221,8 @@ Clean Up 清理 Color Images 彩色圖片 Grayscale Images 灰階圖片 + Monochrome Images 單色圖片 + CCITT Group 4 CCITT 第 4 組 Bicubic downsampling to 雙三次降取樣至 for images above (高於此解析度的圖片) Compression 壓縮 @@ -487,6 +489,14 @@ Applies to 1 page 套用於 1 頁 Applies to {n} pages 套用於 {n} 頁 Replaces the existing one on those pages. 替換這些頁面上的現有內容。 + Checking pages… 正在檢查頁面… + Page {pages} paints its own white backdrop, which would hide the background. Replace it 第 {pages} 頁有自己的白色填色,會遮住背景。取代它 + Pages {pages} paint their own white backdrop, which would hide the background. Replace it 第 {pages} 頁各有自己的白色填色,會遮住背景。取代它們 + Removing the background puts the page's own backdrop back. 移除背景後,頁面自己的白色填色會恢復。 + A picture covers page {pages} (a scan?), so the background won't show there. 第 {pages} 頁被一張圖片覆蓋(掃描檔?),因此那裡不會顯示背景。 + Pictures cover pages {pages} (scans?), so the background won't show there. 第 {pages} 頁被圖片覆蓋(掃描檔?),因此那裡不會顯示背景。 + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. 第 {pages} 頁在加入背景後有變更,因此其本身的白色填色維持關閉。在白紙上看起來一樣。 + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. 第 {pages} 頁在加入背景後有變更,因此它們本身的白色填色維持關閉。在白紙上看起來一樣。 PDF or image PDF 或圖片 No file chosen 未選擇檔案 Page number 頁碼 @@ -2507,3 +2517,6 @@ animation effects Off 關 Rotate the selected pages clockwise 順時針旋轉所選頁面 Extract the selected pages… 擷取所選頁面… Delete the selected pages 刪除所選頁面 + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply 此回覆的選項 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 440c0d13c3..222bb988e4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -86,6 +86,7 @@ use canvas::fling; use canvas::image_pick; use canvas::page_cursor; use canvas::page_pointer; +use canvas::page_router; pub use canvas::scroll_speed; use canvas::signature_drag; use canvas::wheel_boost; @@ -314,6 +315,8 @@ pub struct PdfCraftApp { pub flatten_fill_sign_on_save: bool, /// Point size for new Fill & Sign text and dates, kept across launches. pub fill_text_size: f64, + /// Colour for new Fill & Sign text and dates, kept across launches. + pub fill_text_color: [f64; 3], pub dialog: Option, /// How to ask for the latest release (the desktop app sets it; see `updates`). pub(crate) updates: updates::Updates, @@ -575,6 +578,8 @@ pub struct PdfCraftApp { fonts_cjk: theme::CjkPreference, /// The window title last sent to the platform. pub window_title: String, + /// The web page's title last set: the window title, but always with the file's name. + pub page_title: String, /// The UI control channel, when enabled (`--control`; off by default). control: Option, /// A bookmark being renamed in the Bookmarks panel: (path, text so far). diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index 294d90f2b6..c549ed8e3e 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -3,7 +3,8 @@ //! Header "Comments N" (comments, as Acrobat counts them) with "N replies" under it and search; //! an "Add a comment" box that posts a sticky note on the current page; cards grouped by page. //! Selecting a card selects the comment on the page and opens its reply box and "…" menu (Edit, -//! Set status, Delete). Status replies show as a badge. +//! Set status, Delete), and gives each reply a "…" of its own (Edit, Copy text, Delete). Status +//! replies show as a badge. use std::collections::HashMap; @@ -114,13 +115,14 @@ fn any_contains<'a>(fields: impl IntoIterator, q: &str) -> bool pub(crate) fn show( ui: &mut egui::Ui, t: &Tokens, - info: &DocInfo, + doc: &pdfcraft_engine::Document, view: &mut DocView, prefs: &CommentPrefs, allowed: bool, nav: &mut Option