mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 07:39:08 +00:00
Merge pull request #5426 from FredApps/chore/parity-integration-20261030
Integrate 27 reviewed PDF editing, forms and optimizer PRs
This commit is contained in:
230 files changed
+20555
-1306
No files matched your search
@@ -9,3 +9,4 @@ plan/
|
||||
/assets/models
|
||||
# A craft-fonts checkout used as the optional CRAFT_FONTS_DIR build input (CI and releases put it here).
|
||||
/craft-fonts
|
||||
.claude/
|
||||
@@ -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. `page_modules_take_presses_from_the_router_without_repeating_hit_tests` guards the centralized hit calls and captured-owner handlers; runtime routing tests cover gesture behavior.
|
||||
- **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.
|
||||
|
||||
Generated
+11
@@ -2732,6 +2732,7 @@ version = "0.3.0"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
"hayro-ccitt",
|
||||
"image",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4334,9 +4335,12 @@ name = "pdfcraft-create"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-ccitt",
|
||||
"hayro-jbig2",
|
||||
"hayro-syntax",
|
||||
"image",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-xfa",
|
||||
@@ -4369,6 +4373,7 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-bidi",
|
||||
]
|
||||
@@ -4379,6 +4384,7 @@ version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
"getrandom 0.4.3",
|
||||
"icu_properties",
|
||||
"idna",
|
||||
"image",
|
||||
@@ -4425,7 +4431,10 @@ dependencies = [
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
"hayro-jbig2",
|
||||
"proptest",
|
||||
"thiserror 2.0.21",
|
||||
"weezl 0.2.1",
|
||||
@@ -4508,6 +4517,7 @@ dependencies = [
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"proptest",
|
||||
"thiserror 2.0.21",
|
||||
"zune-jpeg",
|
||||
]
|
||||
@@ -4568,6 +4578,7 @@ dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-edit",
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-forms",
|
||||
"pdfcraft-model",
|
||||
|
||||
@@ -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"
|
||||
# Independent CCITT and JBIG2 codecs, already in the tree through hayro.
|
||||
hayro-ccitt = "0.3"
|
||||
hayro-jbig2 = "0.3"
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
|
||||
+28
-3
@@ -1,7 +1,10 @@
|
||||
# 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-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
|
||||
> **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)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
|
||||
@@ -21,6 +24,7 @@
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7 #2517: a partial rewrite keeps its marker around a kept row, and off the next marked region; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
|
||||
@@ -95,8 +99,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 +155,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 |
|
||||
@@ -200,11 +204,22 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged.
|
||||
- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
|
||||
- **2026-10-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-22 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. When the next paragraph shares the text object, the marker closes before that neighbour instead of after the shared `ET`. The extra marker is not added across two marked-content regions. Estimates unchanged.
|
||||
- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. 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, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next.
|
||||
- **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 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script.
|
||||
- **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 +235,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 +316,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 +466,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 +766,10 @@ 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.
|
||||
## Button export-value refresh (2026-10-30)
|
||||
|
||||
PR #1952 retains per-widget checkbox/radio export-value editing in the split form-authoring and Prepare modules. Caption editing remains supported. Tests cover preserved appearances and selection, invalid/locked refusals, data-format round trips, and Apply/Cancel/Undo. Current-head hosted validation is pending; feature estimates are unchanged.
|
||||
@@ -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,
|
||||
|
||||
@@ -226,16 +226,33 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result<i64
|
||||
/// over the page while it is dragged. `false`, with `doc` unchanged, when the annotation has no
|
||||
/// normal appearance (`/AP /N`) for that render to draw.
|
||||
pub fn isolate_annotation(doc: &mut Document, page: usize, index: usize) -> Result<bool, AnnotError> {
|
||||
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<bool, AnnotError> {
|
||||
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");
|
||||
})?;
|
||||
|
||||
@@ -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<Object> = 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<Object> = 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<ObjRef> {
|
||||
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
|
||||
|
||||
@@ -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<String> = 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -214,6 +220,18 @@ unapplied with a warning.
|
||||
folders. The CLI also exposes it as `pdfcraft-cli batch`. See the
|
||||
[batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits.
|
||||
|
||||
## Editing button export values
|
||||
|
||||
Use `form_set_props` with `field`, `export_value` and optional `widget`
|
||||
(1-based, default 1) to change a checkbox or radio button export value.
|
||||
`form_fields` returns `export_values` in widget order. The selected/default
|
||||
appearance states are preserved; the operation is undoable and survives saving.
|
||||
Widgets sharing an appearance state keep one export value. Bulk edits are not
|
||||
supported for this widget-specific property. Empty values, `Off`, conflicting
|
||||
choices and values over 4096 UTF-8 bytes return an error.
|
||||
|
||||
Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`.
|
||||
|
||||
## Viewer navigation history
|
||||
|
||||
A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through
|
||||
@@ -229,3 +247,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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -53,13 +53,13 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
|
||||
t.color = parse_color(c)?;
|
||||
}
|
||||
if let Some(al) = a.opt_str("align")? {
|
||||
t.align = match al {
|
||||
"left" => TextAlign::Left,
|
||||
"center" | "centre" => TextAlign::Center,
|
||||
"right" => TextAlign::Right,
|
||||
"justify" => TextAlign::Justify,
|
||||
other => return Err(bad(format!("unknown align {other:?}"))),
|
||||
};
|
||||
t.align = parse_align(al)?;
|
||||
// The whole box: no paragraph keeps an alignment of its own unless `aligns` gives it one.
|
||||
t.aligns.clear();
|
||||
}
|
||||
if let Some(aligns) = opt_aligns(a)? {
|
||||
let count = pdfcraft_engine::paragraph_align::count(&t.text);
|
||||
t.aligns = pdfcraft_engine::paragraph_align::tidy(aligns.into_iter().take(count).collect(), t.align);
|
||||
}
|
||||
if let Some(c) = a.opt_num("char_spacing")? {
|
||||
if !(-ADDED_CHAR_SPACING_LIMIT..=ADDED_CHAR_SPACING_LIMIT).contains(&c) {
|
||||
@@ -70,6 +70,37 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_align(al: &str) -> Result<TextAlign> {
|
||||
Ok(match al {
|
||||
"left" => TextAlign::Left,
|
||||
"center" | "centre" => TextAlign::Center,
|
||||
"right" => TextAlign::Right,
|
||||
"justify" => TextAlign::Justify,
|
||||
other => return Err(bad(format!("unknown align {other:?}"))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Most entries `aligns` takes: one per paragraph, and no text has this many.
|
||||
const MAX_ALIGNS: usize = 4096;
|
||||
|
||||
/// `aligns`: each paragraph's (line's) own alignment, or null where it follows `align`.
|
||||
pub(crate) fn opt_aligns(a: &Args) -> Result<Option<Vec<Option<TextAlign>>>> {
|
||||
let Some(v) = a.get("aligns") else { return Ok(None) };
|
||||
let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?;
|
||||
if items.len() > MAX_ALIGNS {
|
||||
return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries")));
|
||||
}
|
||||
items
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) => parse_align(s).map(Some),
|
||||
_ => Err(bad("each entry of aligns is left, center, right, justify or null")),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn content_list(&self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
@@ -160,6 +191,8 @@ impl Automation {
|
||||
match &mut content {
|
||||
AddedContent::Text(t) => {
|
||||
if let Some(s) = a.opt_str("text")? {
|
||||
// Each paragraph keeps its own alignment through the retyping.
|
||||
t.aligns = pdfcraft_engine::paragraph_align::remap(&t.text, s, &t.aligns);
|
||||
t.text = s.to_string();
|
||||
}
|
||||
style(a, t)?;
|
||||
@@ -171,8 +204,8 @@ impl Automation {
|
||||
if a.get("text").is_some() {
|
||||
return Err(bad("images have no text"));
|
||||
}
|
||||
if ["font", "size", "bold", "italic", "color", "align", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
|
||||
return Err(bad("font, size, bold, italic, color, align and char_spacing apply to text"));
|
||||
if ["font", "size", "bold", "italic", "color", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
|
||||
return Err(bad("font, size, bold, italic, color, align, aligns and char_spacing apply to text"));
|
||||
}
|
||||
if let Some(deg) = a.opt_int("rotate")? {
|
||||
if deg.rem_euclid(90) != 0 {
|
||||
|
||||
@@ -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::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub(crate) fn render_preview(&mut self, args: &Args) -> Result<Content> {
|
||||
/// 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<Vec<Content>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary, text_style_levels};
|
||||
use super::paths::{read_input, write_atomic};
|
||||
use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools};
|
||||
use super::{Arc, Automation, Edit, Value, block_overlapping, comments, content, json, tools};
|
||||
|
||||
/// One piece of a tool's result.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -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();
|
||||
@@ -538,7 +538,15 @@ impl Automation {
|
||||
};
|
||||
let index = self.paragraph_index(id, page, k, &blocks)?;
|
||||
let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?;
|
||||
let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text);
|
||||
let width = a.opt_num("width")?;
|
||||
// Explicit row alignments keep the editor's row boundaries; a width-only
|
||||
// reflow rejoins soft wraps while retaining typed newlines and spaces.
|
||||
let text = match a.opt_str("text")? {
|
||||
Some(text) => text.to_owned(),
|
||||
None if a.get("aligns").is_some() => block.editor_text,
|
||||
None if width.is_some() => block.reflow_text.clone(),
|
||||
None => block.text,
|
||||
};
|
||||
let mut style = pdfcraft_engine::BlockStyle {
|
||||
ranges: parse_style_ranges(a.get("ranges"))?,
|
||||
resource: a.opt_str("font_resource")?.map(str::to_owned),
|
||||
@@ -548,7 +556,7 @@ impl Automation {
|
||||
line_spacing: a.opt_num("line_spacing")?,
|
||||
char_spacing: a.opt_num("char_spacing")?,
|
||||
scale: a.opt_num("scale")?,
|
||||
width: a.opt_num("width")?,
|
||||
width,
|
||||
..Default::default()
|
||||
};
|
||||
let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?);
|
||||
@@ -568,13 +576,10 @@ impl Automation {
|
||||
style.color = Some(comments::parse_color(c)?);
|
||||
}
|
||||
if let Some(al) = a.opt_str("align")? {
|
||||
style.align = Some(match al {
|
||||
"left" => pdfcraft_engine::TextAlign::Left,
|
||||
"center" => pdfcraft_engine::TextAlign::Center,
|
||||
"right" => pdfcraft_engine::TextAlign::Right,
|
||||
"justify" => pdfcraft_engine::TextAlign::Justify,
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))),
|
||||
});
|
||||
style.align = Some(content::parse_align(al)?);
|
||||
}
|
||||
if let Some(aligns) = content::opt_aligns(&a)? {
|
||||
style.aligns = aligns;
|
||||
}
|
||||
// Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy).
|
||||
let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]);
|
||||
@@ -636,6 +641,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)?);
|
||||
|
||||
@@ -279,7 +279,8 @@ impl Automation {
|
||||
}
|
||||
|
||||
pub(crate) fn doc_optimize(&mut self, a: &Args) -> Result<Value> {
|
||||
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,38 @@ 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, jbig2, retain)"));
|
||||
let mono = mono.as_object().ok_or_else(|| {
|
||||
ToolError::InvalidArgs("mono must be an object: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|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("jbig2") => MonoCompression::Jbig2,
|
||||
Some("retain") => MonoCompression::Retain,
|
||||
_ => return Err(unknown(c)),
|
||||
};
|
||||
}
|
||||
}
|
||||
for (key, flag) in [
|
||||
("merge_fragments", &mut settings.merge_fragments),
|
||||
("discard_thumbnails", &mut settings.discard_thumbnails),
|
||||
@@ -356,7 +389,9 @@ impl Automation {
|
||||
let pw = self.doc(a).ok().and_then(|d| d.password.as_deref());
|
||||
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?;
|
||||
let root = doc.root()?;
|
||||
doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned)
|
||||
// Through a reference too, as the optimizer reads it.
|
||||
let version = doc.get(root).as_dict()?.get(b"Version")?.clone();
|
||||
doc.resolve(&version).as_name().and_then(valid_version).map(str::to_owned)
|
||||
})
|
||||
.flatten();
|
||||
let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version);
|
||||
@@ -416,6 +451,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<Value> {
|
||||
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<Value> = 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<Value> {
|
||||
use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark};
|
||||
let n = self.doc(a)?.info.pages.len();
|
||||
@@ -483,6 +540,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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Form tools: list fields, fill them (several at once, one undo step), clear the form, and
|
||||
//! prepare a form (add fields one at a time or as a table, change and delete them).
|
||||
|
||||
use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate};
|
||||
use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, NOTATION_NOT_STORABLE, Validate, notation_storable};
|
||||
use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -94,13 +94,20 @@ fn look_arg(v: &Value) -> Result<pdfcraft_engine::FieldLookPatch> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `{"min": 0, "max": 100}` or `"none"`.
|
||||
/// `{"min": 0, "max": 100}` or `"none"`. A null bound is no bound, as `form_fields` shows it.
|
||||
fn validate_arg(v: &Value) -> Result<Validate> {
|
||||
if v.as_str() == Some("none") {
|
||||
return Ok(Validate::None);
|
||||
}
|
||||
let o = v.as_object().ok_or_else(|| bad("validate must be {\"min\": …, \"max\": …} or \"none\""))?;
|
||||
let (min, max) = (o.get("min").and_then(Value::as_f64), o.get("max").and_then(Value::as_f64));
|
||||
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "min" | "max")) {
|
||||
return Err(bad(format!("unknown key {k:?} in validate (min, max)")));
|
||||
}
|
||||
let bound = |k: &str| match o.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(x) => x.as_f64().map(Some).ok_or_else(|| bad(format!("validate {k} must be a number"))),
|
||||
};
|
||||
let (min, max) = (bound("min")?, bound("max")?);
|
||||
if min.is_none() && max.is_none() {
|
||||
return Err(bad("validate needs min, max or both"));
|
||||
}
|
||||
@@ -113,25 +120,67 @@ fn calculate_arg(v: &Value) -> Result<Calculate> {
|
||||
return Ok(Calculate::None);
|
||||
}
|
||||
let o = v.as_object().ok_or_else(|| bad("calculate must be {\"op\": …, \"fields\": […]}, {\"notation\": …} or \"none\""))?;
|
||||
if let Some(n) = o.get("notation").and_then(Value::as_str) {
|
||||
// Before anything else is read, so a script given beside a notation isn't dropped unnoticed.
|
||||
if o.contains_key("script") {
|
||||
return Err(bad("a custom calculate script is set with form_set_script (event: calculate)"));
|
||||
}
|
||||
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "op" | "fields" | "notation")) {
|
||||
return Err(bad(format!("unknown key {k:?} in calculate (op and fields, or notation)")));
|
||||
}
|
||||
// A null is no value, as for the tool's own arguments.
|
||||
let get = |k: &str| o.get(k).filter(|v| !v.is_null());
|
||||
if let Some(n) = get("notation") {
|
||||
if get("op").is_some() || get("fields").is_some() {
|
||||
return Err(bad("calculate takes a notation, or op and fields, not both"));
|
||||
}
|
||||
let n = n.as_str().ok_or_else(|| bad("notation must be a string such as \"Price * Qty\""))?;
|
||||
// Refused rather than stored as a different formula.
|
||||
if !notation_storable(n) {
|
||||
return Err(bad(NOTATION_NOT_STORABLE));
|
||||
}
|
||||
return Ok(Calculate::Notation(n.to_string()));
|
||||
}
|
||||
let op = match o.get("op").and_then(Value::as_str).unwrap_or("") {
|
||||
"sum" => CalcOp::Sum,
|
||||
"product" => CalcOp::Product,
|
||||
"average" => CalcOp::Average,
|
||||
"min" | "minimum" => CalcOp::Minimum,
|
||||
"max" | "maximum" => CalcOp::Maximum,
|
||||
x => return Err(bad(format!("unknown op {x:?} (sum, product, average, min, max)"))),
|
||||
let op = get("op").ok_or_else(|| bad("calculate needs op and fields, or a notation"))?;
|
||||
let op = match op.as_str().ok_or_else(|| bad("op must be one of sum, product, average, min, max"))? {
|
||||
"minimum" => CalcOp::Minimum,
|
||||
"maximum" => CalcOp::Maximum,
|
||||
x => {
|
||||
CalcOp::ALL.into_iter().find(|&op| op_name(op) == x).ok_or_else(|| bad(format!("unknown op {x:?} (sum, product, average, min, max)")))?
|
||||
}
|
||||
};
|
||||
let fields: Vec<String> =
|
||||
o.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect()).unwrap_or_default();
|
||||
let fields = get("fields").and_then(Value::as_array).ok_or_else(|| bad("calculate fields must be a list of field names"))?;
|
||||
let fields: Vec<String> = fields
|
||||
.iter()
|
||||
.map(|x| x.as_str().map(str::to_owned).ok_or_else(|| bad("calculate fields must be field names (strings)")))
|
||||
.collect::<Result<_>>()?;
|
||||
if fields.is_empty() {
|
||||
return Err(bad("calculate needs fields"));
|
||||
}
|
||||
Ok(Calculate::Simple { op, fields })
|
||||
}
|
||||
|
||||
/// An operation's name in `calculate`, as `form_set_props` takes it and the listings show it.
|
||||
fn op_name(op: CalcOp) -> &'static str {
|
||||
match op {
|
||||
CalcOp::Sum => "sum",
|
||||
CalcOp::Product => "product",
|
||||
CalcOp::Average => "average",
|
||||
CalcOp::Minimum => "min",
|
||||
CalcOp::Maximum => "max",
|
||||
}
|
||||
}
|
||||
|
||||
/// A field's calculation as `form_fields` and `form_calculation_order` show it, in
|
||||
/// `form_set_props`'s shape, or `{"script": …}` for a custom Calculate script; `None` without one.
|
||||
fn calculate_json(f: &FormField) -> Option<Value> {
|
||||
match (&f.actions.calculate, &f.actions.scripts.calculate) {
|
||||
(Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })),
|
||||
(Calculate::Notation(n), _) => Some(json!({ "notation": n })),
|
||||
(Calculate::None, Some(js)) => Some(json!({ "script": js })),
|
||||
(Calculate::None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_json(f: &Format) -> Value {
|
||||
match f {
|
||||
Format::None => Value::Null,
|
||||
@@ -220,18 +269,15 @@ impl Automation {
|
||||
if let Validate::Range { min, max } = &f.actions.validate {
|
||||
o.insert("validate".into(), json!({ "min": min, "max": max }));
|
||||
}
|
||||
match &f.actions.calculate {
|
||||
Calculate::Simple { op, fields } => {
|
||||
o.insert("calculate".into(), json!({ "op": op.code().to_lowercase(), "fields": fields }));
|
||||
}
|
||||
Calculate::Notation(n) => {
|
||||
o.insert("calculate".into(), json!({ "notation": n }));
|
||||
}
|
||||
Calculate::None => {}
|
||||
if let Some(c) = calculate_json(f) {
|
||||
o.insert("calculate".into(), c);
|
||||
}
|
||||
if !f.actions.unsupported.is_empty() {
|
||||
o.insert("unsupported_scripts".into(), json!(f.actions.unsupported));
|
||||
}
|
||||
if matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio) {
|
||||
o.insert("export_values".into(), json!((0..f.widgets.len()).map(|i| f.export_of(i)).collect::<Vec<_>>()));
|
||||
}
|
||||
match f.kind {
|
||||
FormFieldKind::PushButton => {
|
||||
o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default()));
|
||||
@@ -418,8 +464,8 @@ impl Automation {
|
||||
}
|
||||
|
||||
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result<Value> {
|
||||
if a.get("widget").is_some() && a.get("caption").is_none() {
|
||||
return Err(bad("widget requires caption"));
|
||||
if a.get("widget").is_some() && a.get("caption").is_none() && a.get("export_value").is_none() {
|
||||
return Err(bad("widget requires caption or export_value"));
|
||||
}
|
||||
let single = a.opt_str("field")?;
|
||||
let bulk = a.get("fields").is_some();
|
||||
@@ -438,7 +484,7 @@ impl Automation {
|
||||
return Err(bad(format!("field {name:?} is listed more than once")));
|
||||
}
|
||||
}
|
||||
for key in ["name", "rect", "rotation", "caption", "widget"] {
|
||||
for key in ["name", "rect", "rotation", "caption", "export_value", "widget"] {
|
||||
if a.get(key).is_some() {
|
||||
return Err(bad(format!("{key} applies to one field; use field instead of fields")));
|
||||
}
|
||||
@@ -502,6 +548,15 @@ impl Automation {
|
||||
};
|
||||
Ok(FieldProps {
|
||||
rect,
|
||||
export_value: a
|
||||
.opt_str("export_value")?
|
||||
.map(|value| -> Result<_> {
|
||||
let index = a.opt_int("widget")?.unwrap_or(1);
|
||||
let index =
|
||||
usize::try_from(index).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?;
|
||||
Ok((index, value.to_owned()))
|
||||
})
|
||||
.transpose()?,
|
||||
name: a.opt_str("name")?.map(str::to_owned),
|
||||
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
|
||||
caption: a
|
||||
@@ -653,15 +708,28 @@ impl Automation {
|
||||
};
|
||||
let order = self.doc(a)?.calculation_order();
|
||||
let issue = |i: &CalculationIssue| match i {
|
||||
CalculationIssue::MissingField { field } => json!({ "issue": "missing_field", "field": field }),
|
||||
CalculationIssue::RunsTooEarly { field } => json!({ "issue": "runs_too_early", "field": field }),
|
||||
CalculationIssue::Cycle { fields } => json!({ "issue": "cycle", "fields": fields }),
|
||||
CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }),
|
||||
CalculationIssue::BuiltNames => json!({ "issue": "built_names" }),
|
||||
};
|
||||
// By field object, not name: two calculated fields can share a name in a malformed form.
|
||||
let doc = self.doc(a)?;
|
||||
let by_obj: std::collections::HashMap<_, &FormField> = doc.form.iter().map(|f| (f.obj, f)).collect();
|
||||
out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>());
|
||||
out["fields"] = order
|
||||
.fields
|
||||
.iter()
|
||||
.map(|f| json!({ "name": f.name, "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::<Vec<_>>() }))
|
||||
.map(|f| {
|
||||
json!({
|
||||
"name": f.name,
|
||||
"formula": by_obj.get(&f.obj).and_then(|x| calculate_json(x)),
|
||||
"reads": f.reads,
|
||||
"listed": f.listed,
|
||||
"issues": f.issues.iter().map(issue).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out["extra"] = json!(order.extra);
|
||||
out["fixable"] = json!(order.fixable());
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -65,7 +65,9 @@ impl Automation {
|
||||
Ok(&mut entry.1)
|
||||
}
|
||||
|
||||
pub(crate) fn page_render(&mut self, a: &Args) -> Result<Content> {
|
||||
/// 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<Vec<Content>> {
|
||||
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<Vec<Content>> {
|
||||
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<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]) -> PageTexts {
|
||||
let extract = |r: &mut PageRenderer, p: usize| {
|
||||
|
||||
@@ -121,7 +121,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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<ToolDef> {
|
||||
)),
|
||||
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." },
|
||||
@@ -391,7 +391,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "from", "to"],
|
||||
)),
|
||||
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
|
||||
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t(
|
||||
@@ -475,8 +475,9 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." },
|
||||
"name": { "type": "string" },
|
||||
"tooltip": { "type": "string" },
|
||||
"export_value": { "type": "string", "description": "Checkbox/radio export value; preserves appearance states and selection. Requires a single field." },
|
||||
"caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." },
|
||||
"widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." },
|
||||
"widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption or export value changes (1-based, default 1). Requires caption or export_value. See form_fields.captions and export_values." },
|
||||
"read_only": { "type": "boolean" },
|
||||
"required": { "type": "boolean" },
|
||||
"multiline": { "type": "boolean" },
|
||||
@@ -492,7 +493,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." },
|
||||
"appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." },
|
||||
"format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." },
|
||||
"validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out) or \"none\"." },
|
||||
"validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." },
|
||||
"calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." },
|
||||
}),
|
||||
&["doc"],
|
||||
@@ -502,7 +503,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with the fields its formula reads (`reads`; a script's names built at run time aren't known) and its `issues`: runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.")
|
||||
t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with its `formula` (as form_fields shows calculate), the fields it reads (`reads`; a script's names built at run time aren't known) and its `issues`: missing_field (it reads `field`, which the form doesn't have, so it gets no value there), runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it) and built_names (its script also reads fields by names it builds at run time, which aren't checked). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.")
|
||||
.cmd("form.calculation_order")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "order": { "type": "array", "items": { "type": "string" } }, "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] }, "fix": { "type": "boolean" } }),
|
||||
@@ -636,6 +637,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
|
||||
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
|
||||
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
|
||||
}),
|
||||
&["doc", "page", "text"],
|
||||
@@ -658,6 +660,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
|
||||
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
|
||||
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
|
||||
"rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" },
|
||||
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." },
|
||||
@@ -919,7 +922,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
)),
|
||||
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 +1032,16 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["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 +1194,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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 +1281,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("ocr.recognize")
|
||||
.with(schema(json!({}), &[])),
|
||||
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.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 (`<name>_Page_<n>_Image_<k>.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 inside other filters, separations) are listed under skipped. Includes unsaved edits.")
|
||||
.cmd("export.all_images")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -1291,7 +1298,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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 +1309,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"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 +1340,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["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(
|
||||
@@ -1420,6 +1428,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"size": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"color": { "type": "string", "description": "#rrggbb" },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
|
||||
"underline": { "type": "boolean" },
|
||||
"line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." },
|
||||
"dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." },
|
||||
@@ -1433,7 +1442,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.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|jbig2|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 JBIG2 (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 +1453,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"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 +1488,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.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}}), &[])),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<Value> {
|
||||
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<String> { 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");
|
||||
@@ -5058,6 +5119,151 @@ fn the_calculation_order_is_read_fixed_and_set() {
|
||||
assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated");
|
||||
}
|
||||
|
||||
/// A malformed form with two calculated fields named `dup`, one summing `src`, the other `x`.
|
||||
fn two_calculations_with_one_name() -> Vec<u8> {
|
||||
let widget = |name: &str, y: u32, reads: &str| {
|
||||
format!(
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>",
|
||||
y + 20
|
||||
)
|
||||
};
|
||||
pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>",
|
||||
"<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>",
|
||||
&widget("dup", 700, "x"),
|
||||
&widget("dup", 660, "src"),
|
||||
&widget("src", 620, "x"),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>",
|
||||
])
|
||||
}
|
||||
|
||||
/// Each row of the calculation order gets its own field's formula, even where two fields share a
|
||||
/// name (a malformed form): the first dup sums src, the second x.
|
||||
#[test]
|
||||
fn the_calculation_order_gives_each_field_its_own_formula() {
|
||||
let dir = workdir("calcdup");
|
||||
std::fs::write(dir.join("dup.pdf"), two_calculations_with_one_name()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "dup.pdf" }))["doc"].as_u64().unwrap();
|
||||
let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc }));
|
||||
assert_eq!(r["calculation_order"], json!(["dup", "src", "dup"]));
|
||||
let formulas: Vec<Value> = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect();
|
||||
assert_eq!(
|
||||
formulas,
|
||||
[json!({ "op": "sum", "fields": ["src"] }), json!({ "op": "sum", "fields": ["x"] }), json!({ "op": "sum", "fields": ["x"] })]
|
||||
);
|
||||
}
|
||||
|
||||
/// The calculation order through the tool shows each formula, in form_set_props's shape or as a
|
||||
/// script, and flags a read of a field the form doesn't have and a script that builds the names
|
||||
/// it reads; form_fields shows a custom script too.
|
||||
#[test]
|
||||
fn the_calculation_order_shows_formulas_and_what_they_cannot_read() {
|
||||
let dir = workdir("calcformulas");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
for (i, name) in ["qty", "price", "sub", "tax", "total"].into_iter().enumerate() {
|
||||
let y = 20 + 30 * i;
|
||||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
|
||||
}
|
||||
let script = "event.value = getField(\"sub\").value / 10 + getField(\"row\" + 1).value;";
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sub", "calculate": { "notation": "qty * pric" } }));
|
||||
ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "tax", "event": "calculate", "script": script }));
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["sub", "tax"] } }));
|
||||
let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc }));
|
||||
assert_eq!(r["calculation_order"], json!(["sub", "tax", "total"]));
|
||||
let formulas: Vec<Value> = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect();
|
||||
assert_eq!(formulas, [json!({ "notation": "qty * pric" }), json!({ "script": script }), json!({ "op": "sum", "fields": ["sub", "tax"] })]);
|
||||
assert_eq!(r["fields"][0]["issues"], json!([{ "issue": "missing_field", "field": "pric" }]));
|
||||
assert_eq!(r["fields"][1]["reads"], json!(["sub"]));
|
||||
assert_eq!(r["fields"][1]["issues"], json!([{ "issue": "built_names" }]));
|
||||
assert_eq!(r["fields"][2]["issues"], json!([]));
|
||||
assert_eq!(r["fixable"], false, "neither is an order to fix");
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let tax = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "tax").unwrap();
|
||||
assert_eq!(tax["calculate"], json!({ "script": script }));
|
||||
// A script is set with form_set_script, and form_set_props says so, also when a notation
|
||||
// comes with it.
|
||||
for calculate in [json!({ "script": script }), json!({ "notation": "qty * 2", "script": script })] {
|
||||
let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "tax", "calculate": calculate }));
|
||||
assert!(e.to_string().contains("form_set_script"), "{calculate}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every operation comes back from form_fields and form_calculation_order as form_set_props takes
|
||||
/// it, so a formula can be read and given back: product and average came back as prd and avg,
|
||||
/// which it refused.
|
||||
#[test]
|
||||
fn every_calculation_op_can_be_read_and_given_back() {
|
||||
let dir = workdir("calcops");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
for (name, y) in [("a", 20), ("b", 60), ("total", 100)] {
|
||||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
|
||||
}
|
||||
for op in ["sum", "product", "average", "min", "max"] {
|
||||
let calculate = json!({ "op": op, "fields": ["a", "b"] });
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate }));
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let shown = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap()["calculate"].clone();
|
||||
let order = ok(&mut a, "form_calculation_order", json!({ "doc": doc }));
|
||||
assert_eq!((&shown, &order["fields"][0]["formula"]), (&calculate, &calculate), "{op}");
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": shown }));
|
||||
}
|
||||
}
|
||||
|
||||
/// form_set_props refuses a calculate or validate it would only partly apply (a key it doesn't
|
||||
/// know, a value of the wrong type, a notation beside op and fields) as a bad argument, before
|
||||
/// the edit runs, and changes nothing. A validate read back from form_fields, with null for the
|
||||
/// bound it doesn't have, is taken back.
|
||||
#[test]
|
||||
fn calculate_and_validate_refuse_what_they_would_drop() {
|
||||
let dir = workdir("calcstrict");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
for (name, y) in [("a", 20), ("b", 60), ("total", 100)] {
|
||||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
|
||||
}
|
||||
let set = json!({ "op": "sum", "fields": ["a", "b"] });
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": set, "validate": { "min": 0, "max": 10 } }));
|
||||
let total = |a: &mut Automation| {
|
||||
let fields = ok(a, "form_fields", json!({ "doc": doc }));
|
||||
fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap().clone()
|
||||
};
|
||||
for (calculate, says) in [
|
||||
(json!({ "notation": "a", "op": "sum", "fields": ["b"] }), "not both"),
|
||||
(json!({ "notation": "a", "op": "sum" }), "not both"),
|
||||
(json!({ "notation": "a", "fields": ["b"] }), "not both"),
|
||||
// A notation that wouldn't read back as itself from the script's comment.
|
||||
(json!({ "notation": "a EVCALC b" }), "can't be stored"),
|
||||
(json!({ "notation": "a */ x /*" }), "can't be stored"),
|
||||
(json!({ "notation": " " }), "can't be stored"),
|
||||
(json!({ "op": "sum", "fields": ["a", 5] }), "field names"),
|
||||
(json!({ "op": "sum", "fields": "a" }), "list of field names"),
|
||||
(json!({ "notation": 5 }), "notation must be a string"),
|
||||
(json!({ "op": 5, "fields": ["a"] }), "op must be"),
|
||||
(json!({ "fields": ["a"] }), "needs op"),
|
||||
(json!({ "op": "sum", "feilds": ["a"] }), "unknown key \"feilds\""),
|
||||
] {
|
||||
// InvalidArgs, not Failed: an unstorable notation is refused by the tool itself, not
|
||||
// only by the edit underneath it.
|
||||
let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate }));
|
||||
assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{calculate}: {e:?}");
|
||||
}
|
||||
for (validate, says) in [(json!({ "min": 0, "max": "100" }), "max must be a number"), (json!({ "min": 0, "mxa": 5 }), "unknown key \"mxa\"")] {
|
||||
let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": validate }));
|
||||
assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{validate}: {e:?}");
|
||||
}
|
||||
let t = total(&mut a);
|
||||
assert_eq!((&t["calculate"], &t["validate"]), (&set, &json!({ "min": 0.0, "max": 10.0 })), "nothing changed");
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": { "min": null, "max": 5 } }));
|
||||
let shown = total(&mut a)["validate"].clone();
|
||||
assert_eq!(shown, json!({ "min": null, "max": 5.0 }));
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": shown }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_tab_orders_are_read_and_set_per_page_through_tools() {
|
||||
let dir = workdir("pagetabs");
|
||||
@@ -6293,6 +6499,15 @@ fn optimizing_through_tools() {
|
||||
let cat_doc = ok(&mut a, "doc_open", json!({ "path": "cat16.pdf" }))["doc"].as_u64().unwrap();
|
||||
let retained = ok(&mut a, "doc_optimize", json!({ "doc": cat_doc, "path": "retained.pdf", "compatibility": "retain", "object_streams": false }));
|
||||
assert_eq!(retained["pdf_version"].as_str(), Some("1.6"), "{retained}");
|
||||
// The same with the catalog's /Version given by reference: read through it.
|
||||
let v16 = cos14.add(pdfcraft_cos::Object::name("1.6"));
|
||||
cos14.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::Ref(v16))).unwrap();
|
||||
let ref16 = pdfcraft_cos::write_full(&cos14, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap();
|
||||
std::fs::write(dir.join("ref16.pdf"), &ref16).unwrap();
|
||||
let ref_doc = ok(&mut a, "doc_open", json!({ "path": "ref16.pdf" }))["doc"].as_u64().unwrap();
|
||||
let by_ref =
|
||||
ok(&mut a, "doc_optimize", json!({ "doc": ref_doc, "path": "retained-ref.pdf", "compatibility": "retain", "object_streams": false }));
|
||||
assert_eq!(by_ref["pdf_version"].as_str(), Some("1.6"), "{by_ref}");
|
||||
|
||||
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "compatibility": "1.3" })), Err(ToolError::InvalidArgs(_))));
|
||||
assert!(matches!(
|
||||
@@ -6372,6 +6587,240 @@ 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<u8> {
|
||||
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<u8> {
|
||||
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<Option<i64>> = 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<bool> = buf[..frame.buffer_size()].as_chunks::<3>().0.iter().map(|p| *p == [255, 255, 255]).collect();
|
||||
let expected: Vec<bool> =
|
||||
(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);
|
||||
}
|
||||
|
||||
/// With `mono: { compression: jbig2 }` a scan's black-and-white image is stored as lossless JBIG2,
|
||||
/// smaller than as Group 4, and exports as the same picture.
|
||||
#[test]
|
||||
fn doc_optimize_stores_black_and_white_images_as_jbig2() {
|
||||
let dir = workdir("optimize-jbig2");
|
||||
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 g4 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "g4.pdf" }));
|
||||
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "jbig2.pdf", "mono": { "compression": "jbig2" } }));
|
||||
assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
assert!(r["image_bytes_after"].as_u64() < g4["image_bytes_after"].as_u64(), "JBIG2 {r}, Group 4 {g4}");
|
||||
let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("jbig2.pdf")).unwrap())).unwrap();
|
||||
let filters: Vec<Vec<u8>> = 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") => s.dict.name(b"Filter").map(<[u8]>::to_vec),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(filters, [b"JBIG2Decode".to_vec()]);
|
||||
// Export ▸ Images gives the copy's picture back as it gives the scan's.
|
||||
let copy = ok(&mut a, "doc_open", json!({ "path": "jbig2.pdf" }))["doc"].as_u64().unwrap();
|
||||
let mut exported = |doc: u64, folder: &str| {
|
||||
let r = ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": folder }));
|
||||
let files = r["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 1, "{r}");
|
||||
std::fs::read(files[0]["path"].as_str().unwrap()).unwrap()
|
||||
};
|
||||
assert_eq!(exported(copy, "from-jbig2"), exported(doc, "from-scan"));
|
||||
}
|
||||
|
||||
/// 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<Vec<u8>> = 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": "jpeg" }), 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"), simple_rule_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<i64>, Option<i64>)> {
|
||||
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"), simple_rule_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<u8> {
|
||||
@@ -6974,7 +7423,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 +7432,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());
|
||||
}
|
||||
|
||||
@@ -8962,8 +9415,8 @@ fn text_box_and_callout_fill_and_border_through_tools() {
|
||||
}
|
||||
|
||||
/// Three paragraphs: Alpha (two rows), Bravo (one row) and Charlie (two rows), at the leading
|
||||
/// of #2516. A third Alpha row brings its last baseline within 2.5 × size of Bravo's, so the next
|
||||
/// read groups the two as one paragraph.
|
||||
/// of #2516. Alpha's last baseline is 35.6 pt above Bravo's; moving Alpha down 21.2 pt leaves
|
||||
/// 14.4 pt, Alpha's own leading, so the next read groups the two as one paragraph.
|
||||
fn three_paragraph_pdf() -> Vec<u8> {
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||||
use std::sync::Arc;
|
||||
@@ -9012,6 +9465,10 @@ fn an_emptied_paragraph_does_not_pass_its_number_to_an_overlapping_neighbour() {
|
||||
/// #2516: a paragraph number names the paragraph `text_paragraphs` listed. When an edit regroups
|
||||
/// the page so that paragraph no longer exists on its own, the next edit by that number is
|
||||
/// refused. It used to rewrite whichever paragraph had taken the number, and that text was gone.
|
||||
///
|
||||
/// Moving Alpha down (no text change) is the regrouping edit: #2517 wraps a rewrite that adds or
|
||||
/// keeps rows in a marked region precisely so it can't be mistaken for a neighbour by geometry
|
||||
/// alone, so a text rewrite can no longer manufacture this scenario; a pure move still can.
|
||||
#[test]
|
||||
fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() {
|
||||
let dir = workdir("text-edit-stale-number");
|
||||
@@ -9023,12 +9480,9 @@ fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() {
|
||||
assert!(listed["paragraphs"][1]["text"].as_str().unwrap().starts_with("Bravo"));
|
||||
|
||||
// Listing again here would renumber the page, so the first edit's result says what moved.
|
||||
let first = ok(
|
||||
&mut a,
|
||||
"text_edit",
|
||||
json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}),
|
||||
);
|
||||
assert_eq!(first["stale_paragraphs"], json!([1, 2]), "the third row regroups Alpha with Bravo: {first}");
|
||||
// Moving Alpha down by 21.2 pt brings its last row within 14.4 pt of Bravo, its own leading.
|
||||
let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2}));
|
||||
assert_eq!(first["stale_paragraphs"], json!([1, 2]), "moving Alpha down regroups it with Bravo: {first}");
|
||||
|
||||
// Paragraph 2 of the earlier listing was Bravo, which is now part of Alpha's paragraph.
|
||||
let stale = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":2,"text":"Bravo paragraph, edited."}));
|
||||
@@ -9043,13 +9497,14 @@ fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() {
|
||||
assert!(relisted["paragraphs"][1]["text"].as_str().unwrap().starts_with("Charlie"));
|
||||
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":2,"text":"Charlie paragraph, edited."}));
|
||||
let text = page_text(&mut a, doc).join(" ");
|
||||
assert!(text.contains("Charlie paragraph, edited.") && text.contains("Alpha paragraph, a third row."), "{text}");
|
||||
assert!(text.contains("Charlie paragraph, edited.") && text.contains("Alpha paragraph, second row."), "{text}");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// An edit that merges its paragraph into a neighbour leaves no paragraph of its own to follow:
|
||||
/// the merged one holds the edited text too, but rewriting it by the edited paragraph's number
|
||||
/// would drop the neighbour's text (#2516).
|
||||
/// would drop the neighbour's text (#2516). Moving Alpha down (see the previous test) is the
|
||||
/// regrouping edit, since a text rewrite is marked precisely so it cannot merge this way (#2517).
|
||||
#[test]
|
||||
fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() {
|
||||
let dir = workdir("text-edit-merged-number");
|
||||
@@ -9057,11 +9512,7 @@ fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() {
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1}));
|
||||
let first = ok(
|
||||
&mut a,
|
||||
"text_edit",
|
||||
json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}),
|
||||
);
|
||||
let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2}));
|
||||
assert!(first.get("paragraph").is_none(), "the result must not present the merged paragraph as the edited one: {first}");
|
||||
|
||||
// Paragraph 1 was Alpha. Alpha now shares a paragraph with Bravo, so the number is refused.
|
||||
@@ -9075,6 +9526,8 @@ fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() {
|
||||
|
||||
/// The editor background preview takes the same paragraph number as `text_edit`, so it hides the
|
||||
/// paragraph the number named in the listing and refuses a number an edit made stale (#2516).
|
||||
/// Moving Alpha down (see the two tests above) is the regrouping edit; a text rewrite is marked
|
||||
/// precisely so it cannot merge this way (#2517).
|
||||
#[test]
|
||||
fn page_render_hide_text_block_refuses_a_paragraph_number_an_earlier_edit_regrouped() {
|
||||
let dir = workdir("hide-text-block-stale-number");
|
||||
@@ -9082,11 +9535,7 @@ fn page_render_hide_text_block_refuses_a_paragraph_number_an_earlier_edit_regrou
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1}));
|
||||
ok(
|
||||
&mut a,
|
||||
"text_edit",
|
||||
json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}),
|
||||
);
|
||||
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2}));
|
||||
|
||||
// Paragraph 2 of the listing was Bravo, now inside Alpha's paragraph; position 2 is Charlie.
|
||||
let stale = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":2}));
|
||||
@@ -9308,6 +9757,46 @@ fn bookmark_list_reports_colour_and_style() {
|
||||
assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved");
|
||||
}
|
||||
|
||||
/// `aligns` gives each line of added text an alignment of its own; `align` alone is the whole box
|
||||
/// again. Text edits take `aligns` for a paragraph's rows the same way.
|
||||
#[test]
|
||||
fn added_text_lines_take_their_own_alignments() {
|
||||
let dir = workdir("paragraph-aligns");
|
||||
let mut a = auto(&dir);
|
||||
let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":"Left line\nRight line","at":[40,50],"width":500,"size":12,"aligns":[null,"right"]}));
|
||||
let rect = |a: &mut Automation, text: &str| -> Vec<f64> {
|
||||
let lines = ok(a, "text_lines", json!({"doc":id,"page":1}));
|
||||
let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap();
|
||||
line["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()
|
||||
};
|
||||
assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0);
|
||||
assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line"));
|
||||
// The whole box centred: the right-aligned line goes with it.
|
||||
ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"}));
|
||||
let right = rect(&mut a, "Right line");
|
||||
assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}");
|
||||
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err());
|
||||
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err());
|
||||
}
|
||||
|
||||
/// Existing-row alignment is withheld until its intent survives later editing sessions.
|
||||
#[test]
|
||||
fn text_edit_row_alignment_is_refused_without_changing_existing_text() {
|
||||
let dir = workdir("paragraph-row-aligns");
|
||||
let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running.) Tj ET";
|
||||
std::fs::write(dir.join("rows.pdf"), win_ansi_paragraph_pdf(body)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "rows.pdf" }))["doc"].as_u64().unwrap();
|
||||
let before = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 }));
|
||||
let refused = err(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "aligns": [null, "right"] }));
|
||||
assert!(matches!(&refused, ToolError::Failed(m) | ToolError::InvalidArgs(m) if m.contains("selected-row alignment")), "{refused:?}");
|
||||
assert_eq!(ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 })), before);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "unchanged.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "unchanged.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "text_lines", json!({ "doc": reopened, "page": 1 })), before);
|
||||
}
|
||||
|
||||
/// An editable combo box (ISO 32000-2 Table 233, Edit) takes a value that isn't one of its
|
||||
/// options, and its page appearance shows it, before and after a save; one without Edit takes
|
||||
/// only its options.
|
||||
@@ -9457,3 +9946,247 @@ fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() {
|
||||
let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone();
|
||||
assert!(city["value"].is_null() || city["value"] == "", "reset clears it rather than restoring the parent's Main St: {}", city["value"]);
|
||||
}
|
||||
|
||||
/// #2517, through the same tools as the report. One blank line is two paragraphs, and editing
|
||||
/// the first leaves the second. The neighbour at y=656.8 is one blank line below (two 14.4 pt
|
||||
/// leadings under the second row). Growing the first paragraph, saving, reopening and editing
|
||||
/// again leaves that neighbour in place. An explicit TL of 27.6 pt stays one paragraph.
|
||||
#[test]
|
||||
fn a_blank_line_stays_a_separate_paragraph_through_tools() {
|
||||
let dir = workdir("blank-line");
|
||||
let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \
|
||||
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET";
|
||||
std::fs::write(dir.join("a.pdf"), helvetica_page(body)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let before = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }));
|
||||
assert_eq!(before["count"], 2, "{before}");
|
||||
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "First paragraph, edited row.\nFirst paragraph, second row." }));
|
||||
let after = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }));
|
||||
assert_eq!(after["count"], 2, "{after}");
|
||||
let second = after["paragraphs"][1]["text"].as_str().unwrap();
|
||||
assert!(second.contains("Second paragraph"), "{second}");
|
||||
|
||||
let grown = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \
|
||||
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbour) Tj ET";
|
||||
std::fs::write(dir.join("grown.pdf"), helvetica_page(grown)).unwrap();
|
||||
let grown_doc = ok(&mut a, "doc_open", json!({ "path": "grown.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": grown_doc, "page": 1 }))["count"], 2);
|
||||
ok(&mut a, "text_edit", json!({ "doc": grown_doc, "page": 1, "paragraph": 1, "text": "One\nTwo\nThree" }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": grown_doc, "path": "grown-out.pdf" }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "grown-out.pdf" }))["doc"].as_u64().unwrap();
|
||||
let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }));
|
||||
assert_eq!(after["count"], 2, "{after}");
|
||||
assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}");
|
||||
ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "text": "One\nTwo\nFour" }));
|
||||
let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }));
|
||||
assert_eq!(after["count"], 2, "{after}");
|
||||
assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}");
|
||||
|
||||
let spaced = "BT /F1 12 Tf 27.6 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj T* (Three) Tj ET";
|
||||
std::fs::write(dir.join("spaced.pdf"), helvetica_page(spaced)).unwrap();
|
||||
let spaced_doc = ok(&mut a, "doc_open", json!({ "path": "spaced.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": spaced_doc, "page": 1 }))["count"], 1);
|
||||
}
|
||||
|
||||
/// #2529, through the same tools as a width drag. The typed double space falls on the wrap.
|
||||
/// Omitting `text` reflows with the saved gap count. An incremental save and a full rewrite
|
||||
/// both still have both spaces after the box is widened again.
|
||||
#[test]
|
||||
fn narrowing_then_widening_keeps_a_typed_double_space_through_tools() {
|
||||
let dir = workdir("double-space-width");
|
||||
std::fs::write(dir.join("a.pdf"), helvetica_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET")).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0]["editor_text"], "One Two");
|
||||
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "width": 45.0 }));
|
||||
let narrow = &ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0];
|
||||
assert_eq!(narrow["editor_text"], "One\nTwo", "{narrow}");
|
||||
for (name, full) in [("narrow-inc.pdf", false), ("narrow-full.pdf", true)] {
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": name, "full": full }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": name }))["doc"].as_u64().unwrap();
|
||||
let block = &ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }))["paragraphs"][0];
|
||||
assert_eq!(block["editor_text"], "One\nTwo", "{name}: {block}");
|
||||
ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "width": 200.0 }));
|
||||
let wide_name = format!("wide-{name}");
|
||||
ok(&mut a, "doc_save", json!({ "doc": saved, "path": wide_name, "full": full }));
|
||||
let wide = ok(&mut a, "doc_open", json!({ "path": wide_name }))["doc"].as_u64().unwrap();
|
||||
let block = &ok(&mut a, "text_paragraphs", json!({ "doc": wide, "page": 1 }))["paragraphs"][0];
|
||||
assert_eq!(block["editor_text"], "One Two", "{name}: the second typed space was lost: {block}");
|
||||
}
|
||||
}
|
||||
|
||||
fn paragraph_texts(a: &mut Automation, doc: u64) -> Vec<String> {
|
||||
ok(a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["text"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The reported edits, through open / text_paragraphs / text_edit / save / reopen / text_extract.
|
||||
/// Covers an explicit TL, the three ways a file moves rows without TL, a blank line written with
|
||||
/// Td, a two-row file that must stay split, and a partial rewrite that adds a row.
|
||||
#[test]
|
||||
fn paragraph_grouping_survives_save_and_reopen_through_tools() {
|
||||
let dir = workdir("paragraph-grouping");
|
||||
let mut a = auto(&dir);
|
||||
for step in [27.6_f64, 28.8] {
|
||||
let y2 = 700.0 - step;
|
||||
let y3 = 700.0 - 2.0 * step;
|
||||
let bodies = [
|
||||
format!("BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj 0 -{step} Td (Two) Tj 0 -{step} Td (Three) Tj ET"),
|
||||
format!(
|
||||
"BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y2} Tm (Two) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y3} Tm (Three) Tj ET"
|
||||
),
|
||||
format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {y2} Td (Two) Tj ET BT /F1 12 Tf 72 {y3} Td (Three) Tj ET"),
|
||||
];
|
||||
for (i, body) in bodies.iter().enumerate() {
|
||||
let path = format!("step-{step}-{i}.pdf");
|
||||
std::fs::write(dir.join(&path), helvetica_page(body)).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap();
|
||||
let paras = paragraph_texts(&mut a, doc);
|
||||
assert_eq!(paras.len(), 1, "{path}: {paras:?}");
|
||||
assert!(paras[0].contains("One") && paras[0].contains("Two") && paras[0].contains("Three"), "{path}: {paras:?}");
|
||||
let extracted = page_text(&mut a, doc);
|
||||
assert!(extracted[0].contains("One") && extracted[0].contains("Three"), "{path}: {extracted:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// One gap and no third row: still two paragraphs.
|
||||
let pair = format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET", 700.0 - 27.6);
|
||||
std::fs::write(dir.join("pair.pdf"), helvetica_page(&pair)).unwrap();
|
||||
let pair_doc = ok(&mut a, "doc_open", json!({ "path": "pair.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, pair_doc).len(), 2);
|
||||
|
||||
// Blank line written with Td, not TL. The inner step repeats; the break does not.
|
||||
let inner = 14.4_f64;
|
||||
let blank = format!(
|
||||
"BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET \
|
||||
BT /F1 12 Tf 72 {} Td (Neighbour) Tj ET BT /F1 12 Tf 72 {} Td (Line) Tj ET",
|
||||
700.0 - inner,
|
||||
700.0 - 3.0 * inner,
|
||||
700.0 - 4.0 * inner
|
||||
);
|
||||
std::fs::write(dir.join("blank.pdf"), helvetica_page(&blank)).unwrap();
|
||||
let blank_doc = ok(&mut a, "doc_open", json!({ "path": "blank.pdf" }))["doc"].as_u64().unwrap();
|
||||
let blank_paras = paragraph_texts(&mut a, blank_doc);
|
||||
assert_eq!(blank_paras.len(), 2, "{blank_paras:?}");
|
||||
assert!(blank_paras[0].contains("One") && blank_paras[0].contains("Two"), "{blank_paras:?}");
|
||||
assert!(blank_paras[1].contains("Neighbour") && blank_paras[1].contains("Line"), "{blank_paras:?}");
|
||||
|
||||
// The first row is unchanged. Save incremental, reopen, edit again, save in full, reopen.
|
||||
let partial = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \
|
||||
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET";
|
||||
std::fs::write(dir.join("partial.pdf"), helvetica_page(partial)).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, doc).len(), 2);
|
||||
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" }));
|
||||
let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "partial.pdf" }));
|
||||
assert_eq!(incremental["incremental"], true, "{incremental}");
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays."]);
|
||||
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" }));
|
||||
let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "partial-full.pdf", "full": true }));
|
||||
assert_eq!(full["incremental"], false, "{full}");
|
||||
let again = ok(&mut a, "doc_open", json!({ "path": "partial-full.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays."]);
|
||||
let extracted = page_text(&mut a, again);
|
||||
assert!(extracted[0].contains("Neighbor stays.") && extracted[0].contains("Four"), "{extracted:?}");
|
||||
}
|
||||
|
||||
/// Editing the only paragraph on the page, while leaving its second row in the source, stays
|
||||
/// one paragraph through save and reopen. A neighbour in a different marked region stays in
|
||||
/// its own paragraph.
|
||||
#[test]
|
||||
fn a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools() {
|
||||
let dir = workdir("paragraph-tail");
|
||||
let mut a = auto(&dir);
|
||||
let only = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET";
|
||||
std::fs::write(dir.join("only.pdf"), helvetica_page(only)).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row."]);
|
||||
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." }));
|
||||
assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row."]);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "only.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row."]);
|
||||
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." }));
|
||||
assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two."]);
|
||||
|
||||
let marked = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC \
|
||||
/P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC";
|
||||
std::fs::write(dir.join("marked.pdf"), helvetica_page(marked)).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, doc).len(), 2);
|
||||
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." }));
|
||||
assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row.", "Neighbor stays."]);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row.", "Neighbor stays."]);
|
||||
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." }));
|
||||
assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two.", "Neighbor stays."]);
|
||||
}
|
||||
|
||||
/// Both ends of one text object, through open / text_edit / save / reopen / text_extract.
|
||||
/// Editing the last paragraph must not take the one before it, and editing the first must
|
||||
/// not take the one after it. Incremental and full saves.
|
||||
#[test]
|
||||
fn a_shared_text_object_keeps_both_paragraphs_through_tools() {
|
||||
let dir = workdir("shared-text-object");
|
||||
let mut a = auto(&dir);
|
||||
let body =
|
||||
"BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj T* T* (Neighbor stays.) Tj T* (Neighbor next.) Tj ET";
|
||||
std::fs::write(dir.join("last.pdf"), helvetica_page(body)).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Neighbor next."]);
|
||||
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nChanged." }));
|
||||
assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]);
|
||||
let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "last.pdf" }));
|
||||
assert_eq!(incremental["incremental"], true, "{incremental}");
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]);
|
||||
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nEdited." }));
|
||||
let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "last-full.pdf", "full": true }));
|
||||
assert_eq!(full["incremental"], false, "{full}");
|
||||
let again = ok(&mut a, "doc_open", json!({ "path": "last-full.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Alpha next row.", "Neighbor stays. Edited."]);
|
||||
let extracted = page_text(&mut a, again);
|
||||
assert!(extracted[0].contains("Alpha first row.") && extracted[0].contains("Edited."), "{extracted:?}");
|
||||
|
||||
std::fs::write(dir.join("first.pdf"), helvetica_page(body)).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "first.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays. Neighbor next."]);
|
||||
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "first-full.pdf", "full": true }));
|
||||
let again = ok(&mut a, "doc_open", json!({ "path": "first-full.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays. Neighbor next."]);
|
||||
let extracted = page_text(&mut a, again);
|
||||
assert!(extracted[0].contains("Four") && extracted[0].contains("Neighbor stays."), "{extracted:?}");
|
||||
}
|
||||
|
||||
/// Unambiguous separated one-pixel rules test the requested scale independently of the
|
||||
/// conservative local-tone guard. Ragged text can legitimately be retained by that guard.
|
||||
fn simple_rule_scan_drawn(content: &[u8]) -> Vec<u8> {
|
||||
use pdfcraft_cos::{Document, Object, SaveOptions, Stream, write_full};
|
||||
let mut doc = Document::open(std::sync::Arc::new(scan_drawn(content))).unwrap();
|
||||
for number in doc.object_numbers() {
|
||||
let reference = pdfcraft_cos::ObjRef::new(number, doc.generation(number));
|
||||
let object = doc.get(reference);
|
||||
let Object::Stream(image) = &*object else { continue };
|
||||
if image.dict.name(b"Subtype") != Some(b"Image") {
|
||||
continue;
|
||||
}
|
||||
let mut data = vec![0xFF; 125 * 400];
|
||||
for y in 0..400 {
|
||||
data[y * 125] &= 0x7F;
|
||||
data[y * 125 + 124] &= 0xFE;
|
||||
}
|
||||
doc.set(reference, Object::Stream(Stream::flate(image.dict.clone(), &data)));
|
||||
}
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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<String> = 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<u8> {
|
||||
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<String> = 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::<Vec<_>>()
|
||||
.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<u8> {
|
||||
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<String> =
|
||||
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);
|
||||
}
|
||||
@@ -151,3 +151,26 @@ fn bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically() {
|
||||
assert_eq!(bytes(&a, id), before);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn button_export_edit_is_one_undo_step_and_survives_save() {
|
||||
let (mut a, id, dir) = setup("button-export");
|
||||
tool(&mut a, "form_add_field", json!({"doc": id, "page": 1, "type": "checkbox", "name": "agree", "rect": [20, 20, 40, 40]}));
|
||||
tool(&mut a, "form_fill", json!({"doc": id, "values": {"agree": true}}));
|
||||
let before = bytes(&a, id);
|
||||
call(&mut a, json!({"doc": id, "field": "agree", "export_value": "Accepted", "widget": 1}));
|
||||
let f = tool(&mut a, "form_fields", json!({"doc": id}));
|
||||
let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap();
|
||||
assert_eq!(check["export_values"], json!(["Accepted"]));
|
||||
assert_eq!(check["value"], true);
|
||||
tool(&mut a, "edit_undo", json!({"doc": id}));
|
||||
assert_eq!(bytes(&a, id), before);
|
||||
tool(&mut a, "edit_redo", json!({"doc": id}));
|
||||
tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"}));
|
||||
let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
let f = tool(&mut a, "form_fields", json!({"doc": reopened}));
|
||||
let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap();
|
||||
assert_eq!(check["export_values"], json!(["Accepted"]));
|
||||
assert_eq!(check["value"], true);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
@@ -24,6 +24,9 @@ let obj = doc.get(ObjRef { num: 12, gen: 0 }); // load (and cache): Arc<Ob
|
||||
let real = doc.resolve(&obj); // follow one indirect reference
|
||||
let d = doc.dict(&obj); // Option<Dict>, 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).
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
@@ -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<bool> = 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<Dict> {
|
||||
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<pdfcraft_filters::CcittParams> {
|
||||
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<Vec<u8>, 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<Vec<u8>, 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<Arc<Object>, 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<Object>),
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Held<'_> {
|
||||
type Target = Object;
|
||||
|
||||
fn deref(&self) -> &Object {
|
||||
match self {
|
||||
Held::Direct(o) => o,
|
||||
Held::Resolved(o) => o,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -818,18 +818,6 @@ pub(super) fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
(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<u8> {
|
||||
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
|
||||
|
||||
@@ -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::<Vec<u8>>();
|
||||
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<Object>, 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<u8>| {
|
||||
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::<String>().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();
|
||||
|
||||
@@ -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<Vec<(Filter, Params)>, pdfcraft_filters::FilterError> {
|
||||
pub(crate) fn readable_filters(&self) -> Result<Vec<(Filter, Params)>, 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()),
|
||||
|
||||
@@ -10,10 +10,13 @@ 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.
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
# Already in the graph through hayro: decodes JBIG2 images for Export ▸ Images.
|
||||
hayro-jbig2.workspace = true
|
||||
png = "0.18"
|
||||
tiff = "0.11"
|
||||
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
|
||||
@@ -24,5 +27,8 @@ 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 }
|
||||
pdfcraft-filters = { workspace = true }
|
||||
+14
-1
@@ -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
|
||||
@@ -25,9 +27,20 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
|
||||
Every function returns a `pdfcraft_cos::Document`; the caller writes it.
|
||||
|
||||
Image export and Save Image As apply each lossless image's Decode pair before converting its color components.
|
||||
JBIG2 images and masks (JBIG2 as their only filter) are decoded with `hayro-jbig2`, their `/JBIG2Globals` included,
|
||||
as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page has another width than the image, or more
|
||||
pixels than an exported image may have, or whose globals decode to more than 32 MiB (they are decoded before the
|
||||
page's size is known, so they have a limit of their own), is skipped; such a mask is left out and the image
|
||||
exported opaque, as JPX and CCITT masks are.
|
||||
Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples.
|
||||
Missing, null or malformed Decode arrays keep the default mapping.
|
||||
Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries.
|
||||
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.
|
||||
+121
-10
@@ -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, separations) are
|
||||
//! reported, never silently left out.
|
||||
|
||||
use std::collections::HashSet;
|
||||
@@ -100,6 +100,116 @@ fn filters(s: &Stream) -> Vec<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The samples of image `s`, decoded: through its filters, with a `/DecodeParms` dictionary given
|
||||
/// by reference, or as an array's entry, or with numbers inside by reference, read too (a predictor
|
||||
/// given so would otherwise be lost), or, for a JBIG2 image
|
||||
/// (its only filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples
|
||||
/// each starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7).
|
||||
fn decoded(doc: &Document, s: &Stream) -> Result<Vec<u8>, String> {
|
||||
match filters(s).as_slice() {
|
||||
[f] if f == b"JBIG2Decode" => jbig2(doc, s),
|
||||
_ => doc.decoded_image(s).map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/DecodeParms` of a stream with one filter: a dictionary, or the one in an array (7.4.2).
|
||||
fn decode_parms(doc: &Document, s: &Stream) -> Dict {
|
||||
let Some(p) = s.dict.get(b"DecodeParms").map(|p| doc.resolve(p)) else { return Dict::default() };
|
||||
let p = match &*p {
|
||||
Object::Array(a) => match a.first() {
|
||||
Some(first) => doc.resolve(first),
|
||||
None => return Dict::default(),
|
||||
},
|
||||
_ => p,
|
||||
};
|
||||
p.as_dict().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The most an image's `/JBIG2Globals` decode to: the symbol and pattern dictionaries an encoder
|
||||
/// shares between pages, themselves coded, so far less than any stream may decode to
|
||||
/// ([`pdfcraft_cos::MAX_DECODED`], 1 GiB). Globals past it refuse the image.
|
||||
const MAX_GLOBALS: usize = 32 << 20;
|
||||
|
||||
/// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller
|
||||
/// checked against the export's size limit, and within that limit itself: the decoder decodes the
|
||||
/// whole page, rows past the image's height too, so a small `/Height` over a far taller page
|
||||
/// would cost more than the limit allows. Rows past the image's height are dropped, and too few
|
||||
/// rows are refused by the caller. Globals that can't be read refuse the image, rather than
|
||||
/// decoding it without them. They are decoded before the page's size is known, so the page's
|
||||
/// limit can't bound them; they have [`MAX_GLOBALS`] of their own.
|
||||
fn jbig2(doc: &Document, s: &Stream) -> Result<Vec<u8>, String> {
|
||||
let globals = match decode_parms(doc, s).get(b"JBIG2Globals").map(|g| doc.resolve(g)).as_deref() {
|
||||
Some(Object::Stream(g)) => {
|
||||
Some(doc.with_direct_parms(g).decoded_within(MAX_GLOBALS).map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let image = hayro_jbig2::Image::new_embedded(&s.raw, globals.as_deref()).map_err(|e| format!("the JBIG2 image can't be read: {e}"))?;
|
||||
if s.dict.int(b"Width") != Some(i64::from(image.width())) {
|
||||
return Err("the JBIG2 image's width isn't the image's width".into());
|
||||
}
|
||||
if u64::from(image.width()).saturating_mul(u64::from(image.height())) > MAX_PIXELS {
|
||||
return Err(format!("the JBIG2 page, {} × {}, is too large to export", image.width(), image.height()));
|
||||
}
|
||||
let height = s.dict.int(b"Height").and_then(|h| usize::try_from(h).ok()).unwrap_or(0);
|
||||
let mut rows = Rows::new(image.width() as usize, height);
|
||||
image.decode(&mut rows).map_err(|e| format!("the JBIG2 image can't be decoded: {e}"))?;
|
||||
Ok(rows.out)
|
||||
}
|
||||
|
||||
/// Rows of one-bit samples, each starting on a byte, filled from a decoder's pixels: `columns`
|
||||
/// a row and at most `rows` rows. Pixels past a row's end and rows past the last are dropped, so
|
||||
/// the output is bounded by the size the caller gave.
|
||||
struct Rows {
|
||||
out: Vec<u8>,
|
||||
row: Vec<u8>,
|
||||
x: usize,
|
||||
columns: usize,
|
||||
rows: usize,
|
||||
done: usize,
|
||||
}
|
||||
|
||||
impl Rows {
|
||||
fn new(columns: usize, rows: usize) -> Self {
|
||||
Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0 }
|
||||
}
|
||||
|
||||
/// `count` pixels, as 1 samples when `one`.
|
||||
fn push(&mut self, one: bool, count: usize) {
|
||||
let end = self.x.saturating_add(count);
|
||||
if one {
|
||||
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;
|
||||
}
|
||||
|
||||
fn end_row(&mut self) {
|
||||
if self.done < self.rows {
|
||||
self.out.extend_from_slice(&self.row);
|
||||
self.done += 1;
|
||||
}
|
||||
self.row.fill(0);
|
||||
self.x = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// JBIG2 codes black as 1, which `JBIG2Decode` gives as 0.
|
||||
impl hayro_jbig2::Decoder for Rows {
|
||||
fn push_pixel(&mut self, black: bool) {
|
||||
self.push(!black, 1);
|
||||
}
|
||||
fn push_pixel_chunk(&mut self, black: bool, chunks: u32) {
|
||||
self.push(!black, (chunks as usize).saturating_mul(8));
|
||||
}
|
||||
fn next_line(&mut self) {
|
||||
self.end_row();
|
||||
}
|
||||
}
|
||||
|
||||
/// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft
|
||||
/// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as
|
||||
/// alpha, JPEG images included, so cut-out images keep their transparent background.
|
||||
@@ -108,8 +218,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
|
||||
let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1;
|
||||
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"JBIG2Decode") if f.len() > 1 => return Err("JBIG2 images inside other filters 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 +257,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
|
||||
space = decoded;
|
||||
(samples, 8)
|
||||
} else {
|
||||
let data = s.decoded().map_err(|e| e.to_string())?;
|
||||
let data = decoded(doc, s)?;
|
||||
let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) };
|
||||
let bpc = match bpc {
|
||||
1 => 1,
|
||||
@@ -403,9 +512,11 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
|
||||
};
|
||||
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 +577,7 @@ fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8
|
||||
}
|
||||
|
||||
/// A one-component mask image as 8-bit values (its `/Decode` interval applied), scaled to
|
||||
/// `w` × `h`. `None` when it can't be decoded (JPEG 2000, JBIG2 and CCITT masks).
|
||||
/// `w` × `h`. `None` when it can't be decoded (JPEG 2000 masks).
|
||||
fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
|
||||
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 +587,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
|
||||
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") => return None,
|
||||
_ => {
|
||||
let bpc = if stencil { 1 } else { m.dict.int(b"BitsPerComponent").unwrap_or(8) };
|
||||
let bpc = match bpc {
|
||||
@@ -488,7 +599,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
|
||||
_ => 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(&decoded(doc, 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()) {
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<i64>) -> Option<Vec<u8>> {
|
||||
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<u8>, 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<u8>, 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<u8>, 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))
|
||||
}
|
||||
|
||||
|
||||
+872
-1
@@ -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<u8> {
|
||||
// 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<bool> =
|
||||
(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<u8> {
|
||||
let mut tif = std::io::Cursor::new(Vec::new());
|
||||
@@ -1338,3 +1380,832 @@ 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<Vec<u8>>, icc: Option<Vec<u8>>) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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<Vec<bool>> {
|
||||
struct Rows(Vec<Vec<bool>>, Vec<bool>, 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<Vec<bool>> = (0..h as usize).map(|y| (0..w as usize).map(|x| white(x, y)).collect()).collect();
|
||||
let grey: Vec<u8> = expected.iter().flatten().map(|&wh| if wh { 255 } else { 0 }).collect();
|
||||
let rgb: Vec<u8> = 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::<Vec<_>>(), 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<u8>)> = 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<u8>)> = others.into_iter().enumerate().map(|(i, p)| (format!("o{i}.png"), p)).collect();
|
||||
let doc = reopen(&from_images(&files).unwrap());
|
||||
let kept: Vec<(Option<i64>, Option<Vec<u8>>)> =
|
||||
(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<bool> {
|
||||
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<bool> =
|
||||
(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<u8>| {
|
||||
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<u8> = 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::<String>().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<u8> = 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<u8> {
|
||||
// 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<Vec<Vec<u32>>> {
|
||||
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<u32>], w: u32, h: u32) -> Vec<bool> {
|
||||
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<bool> = (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<bool> = 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<u8>| {
|
||||
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<u8> = (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<u8> = (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::<Vec<_>>(), 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");
|
||||
}
|
||||
|
||||
/// An image whose PNG predictor is given by reference exports as its picture: its `/DecodeParms`
|
||||
/// dictionary given by reference, as an array's entry, or direct with its numbers by reference. So
|
||||
/// do an indexed image whose lookup table is a stream with its `/DecodeParms` given by reference,
|
||||
/// and a JBIG2 image whose `/JBIG2Globals` stream has them so. `Stream::decoded` reads only a
|
||||
/// direct `/DecodeParms` with direct numbers: the row tags stayed in the samples, and the picture
|
||||
/// came out shifted.
|
||||
#[test]
|
||||
fn images_whose_predictor_is_given_by_reference_export_as_their_picture() {
|
||||
// PNG predictor parameters for `columns` one-byte samples a row.
|
||||
let parms = |columns: i64| {
|
||||
let mut p = Dict::new();
|
||||
p.set(b"Predictor".to_vec(), Object::Int(15));
|
||||
p.set(b"Colors".to_vec(), Object::Int(1));
|
||||
p.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
p.set(b"Columns".to_vec(), Object::Int(columns));
|
||||
p
|
||||
};
|
||||
// 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 grey: Vec<u8> = [10u8, 20, 30, 40, 50, 60, 70, 80].iter().flat_map(|v| [*v, *v, *v, 255]).collect();
|
||||
for form in ["a reference", "an array of a reference", "numbers by reference"] {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mut d = image_dict(4, 2, "DeviceGray", 8);
|
||||
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
let decode_parms = match form {
|
||||
"numbers by reference" => {
|
||||
let mut p = parms(4);
|
||||
for key in [&b"Predictor"[..], b"Columns"] {
|
||||
let v = p.get(key).cloned().unwrap();
|
||||
p.set(key.to_vec(), Object::Ref(doc.add(v)));
|
||||
}
|
||||
Object::Dict(p)
|
||||
}
|
||||
"a reference" => Object::Ref(doc.add(Object::Dict(parms(4)))),
|
||||
_ => Object::Array(vec![Object::Ref(doc.add(Object::Dict(parms(4))))]),
|
||||
};
|
||||
d.set(b"DecodeParms".to_vec(), decode_parms);
|
||||
Stream::from_raw(d, predicted.clone())
|
||||
});
|
||||
assert_eq!(exported_rgba(&doc), ("png", grey.clone()), "/DecodeParms as {form}");
|
||||
}
|
||||
// An indexed image's lookup table, red then blue, as a stream behind a "None" row tag.
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mut t = Dict::new();
|
||||
t.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
t.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(6)))));
|
||||
let table = doc.add(Object::Stream(Stream::from_raw(t, pdfcraft_filters::encode_flate(&[0, 255, 0, 0, 0, 0, 255]))));
|
||||
let mut d = image_dict(4, 1, "DeviceGray", 8);
|
||||
d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), Object::Ref(table)]));
|
||||
Stream::from_raw(d, vec![0, 1, 0, 1])
|
||||
});
|
||||
let (red, blue) = ([255, 0, 0, 255], [0, 0, 255, 255]);
|
||||
assert_eq!(exported_rgba(&doc), ("png", [red, blue, red, blue].concat()), "the lookup table's /DecodeParms by reference");
|
||||
// A JBIG2 image whose globals hold one extension segment (ISO/IEC 14492 7.4.14) a decoder
|
||||
// ignores, behind a "None" row tag. With the tag left in, the globals can't be read and the
|
||||
// image is skipped.
|
||||
let segment = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
|
||||
let bits = [0xF0u8, 0x0F, 0xAA, 0x55];
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mut g = Dict::new();
|
||||
g.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
g.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(segment.len() as i64)))));
|
||||
let tagged: Vec<u8> = [0].iter().chain(&segment).copied().collect();
|
||||
let globals = doc.add(Object::Stream(Stream::from_raw(g, pdfcraft_filters::encode_flate(&tagged))));
|
||||
let mut decode_parms = Dict::new();
|
||||
decode_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
|
||||
let mut d = image_dict(16, 2, "DeviceGray", 1);
|
||||
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms));
|
||||
Stream::from_raw(d, pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap())
|
||||
});
|
||||
let picture: Vec<u8> = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255, 255, 255, 255] } else { [0, 0, 0, 255] }).collect();
|
||||
assert_eq!(exported_rgba(&doc), ("png", picture), "the JBIG2 globals' /DecodeParms by reference");
|
||||
}
|
||||
|
||||
/// An image the optimizer stored as JBIG2 symbols (a symbol dictionary and text regions, rather
|
||||
/// than one generic region) exports as its picture.
|
||||
#[test]
|
||||
fn symbol_coded_jbig2_images_export_as_their_picture() {
|
||||
const LETTERS: [[&str; 7]; 3] = [
|
||||
["#####", "#....", "####.", "#....", "#....", "#....", "#####"],
|
||||
[".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."],
|
||||
["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"],
|
||||
];
|
||||
let (w, h) = (400usize, 40usize);
|
||||
let stride = w.div_ceil(8);
|
||||
// 1 samples white, as a grey image's are; the letters' pixels 0.
|
||||
let mut bits = vec![0xFFu8; stride * h];
|
||||
for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() {
|
||||
for (k, x) in (3..w - 6).step_by(7).enumerate() {
|
||||
for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() {
|
||||
for (dx, c) in row.bytes().enumerate() {
|
||||
if c == b'#' {
|
||||
bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let jbig2 = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap();
|
||||
let generic = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap();
|
||||
assert!(jbig2.len() < generic.len(), "symbol coded: {} bytes, generic {}", jbig2.len(), generic.len());
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |_| {
|
||||
let mut d = image_dict(w as i64, h as i64, "DeviceGray", 1);
|
||||
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
Stream::from_raw(d, jbig2)
|
||||
});
|
||||
let picture: Vec<u8> = (0..w * h)
|
||||
.flat_map(|i| if bits[i / w * stride + i % w / 8] & (0x80 >> (i % w % 8)) != 0 { [255, 255, 255, 255] } else { [0, 0, 0, 255] })
|
||||
.collect();
|
||||
assert_eq!(exported_rgba(&doc), ("png", picture));
|
||||
}
|
||||
|
||||
/// Export ▸ Images reads JBIG2 images (ISO 32000-2 §7.4.7), JBIG2 black as 0: as the image, and as
|
||||
/// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in
|
||||
/// an array, so globals that can't be read skip the image rather than being ignored. Rows past the
|
||||
/// image's height are left out; a JBIG2 page of another width, or with fewer rows than the image,
|
||||
/// or larger than an exported image may be (however small the image's `/Height`), or data that
|
||||
/// isn't JBIG2, skips the image.
|
||||
#[test]
|
||||
fn jbig2_images_export_as_their_picture() {
|
||||
let (w, h) = (203i64, 41i64);
|
||||
let stride = 26;
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let bits: Vec<u8> = (0..stride * h as usize)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed as u8
|
||||
})
|
||||
.collect();
|
||||
// The first `rows` rows as RGBA: a 1 sample white, a 0 black.
|
||||
let picture = |rows: i64| -> Vec<u8> {
|
||||
(0..rows as usize)
|
||||
.flat_map(|y| (0..w as usize).map(move |x| (x, y)))
|
||||
.flat_map(|(x, y)| {
|
||||
let v = if bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0 { 255 } else { 0 };
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap();
|
||||
// A page whose image is `data` as JBIG2, `width` × `height`. With `globals`, its /DecodeParms
|
||||
// (an array when `.1`) name a globals stream; with `mask`, it is the stencil /Mask of a grey
|
||||
// image.
|
||||
let page = |width: i64, height: i64, data: &[u8], globals: Option<(&[u8], bool)>, mask: bool| {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mut d = image_dict(width, height, "DeviceGray", 1);
|
||||
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
if let Some((g, in_array)) = globals {
|
||||
let g = doc.add(Object::Stream(Stream::from_raw(Dict::new(), g.to_vec())));
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g));
|
||||
d.set(b"DecodeParms".to_vec(), if in_array { Object::Array(vec![Object::Dict(parms)]) } else { Object::Dict(parms) });
|
||||
}
|
||||
if !mask {
|
||||
return Stream::from_raw(d, data.to_vec());
|
||||
}
|
||||
d.remove(b"ColorSpace");
|
||||
d.set(b"ImageMask".to_vec(), Object::Bool(true));
|
||||
let m = doc.add(Object::Stream(Stream::from_raw(d, data.to_vec())));
|
||||
let mut image = image_dict(width, height, "DeviceGray", 8);
|
||||
image.set(b"Mask".to_vec(), Object::Ref(m));
|
||||
Stream::from_raw(image, vec![128; (width * height) as usize])
|
||||
});
|
||||
doc
|
||||
};
|
||||
let skipped = |doc: &Document| {
|
||||
let out = extract_images(doc, &[0], 0);
|
||||
out.images.is_empty() && out.skipped.len() == 1
|
||||
};
|
||||
|
||||
assert_eq!(exported_rgba(&page(w, h, &jbig2, None, false)), ("png", picture(h)));
|
||||
assert_eq!(exported_rgba(&page(w, h, &jbig2, Some((&[][..], false)), false)).1, picture(h), "empty globals");
|
||||
for in_array in [false, true] {
|
||||
assert!(skipped(&page(w, h, &jbig2, Some((&b"not JBIG2"[..], in_array)), false)), "unreadable globals, in an array: {in_array}");
|
||||
}
|
||||
assert_eq!(exported_rgba(&page(w, h - 5, &jbig2, None, false)).1, picture(h - 5), "rows past the height");
|
||||
for (width, height, data) in [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h, &b"not JBIG2"[..])] {
|
||||
assert!(skipped(&page(width, height, data, None, false)), "{width} × {height}, {} bytes", data.len());
|
||||
}
|
||||
// The same stream with its page information (the first segment's data, after its 11-byte
|
||||
// header: width, then height) claiming a 20,000 × 10,000 page: 200 million pixels, over the
|
||||
// export's 100 million, behind an image of 20,000 × 4. The decoder would decode the whole page
|
||||
// (it takes pages of up to about 2^28 pixels), so it is refused first.
|
||||
let mut large = jbig2.clone();
|
||||
large[11..15].copy_from_slice(&20_000u32.to_be_bytes());
|
||||
large[15..19].copy_from_slice(&10_000u32.to_be_bytes());
|
||||
assert!(skipped(&page(20_000, 4, &large, None, false)), "a page over the export's size");
|
||||
// Refused the same ways as a stencil /Mask, it is left out: the image exports opaque.
|
||||
for (width, height, mask) in [(w + 8, h, &jbig2), (20_000, 4, &large)] {
|
||||
let rgba = exported_rgba(&page(width, height, mask, None, true)).1;
|
||||
assert_eq!(rgba.len() as i64, width * height * 4, "a {width}-wide mask");
|
||||
assert!(alphas(&rgba).iter().all(|a| *a == 255), "a refused {width}-wide mask is left out");
|
||||
}
|
||||
// A page of exactly the budget, 20,000 × 5,000, is decoded; one row more is refused.
|
||||
for (rows, refused) in [(5_000u32, false), (5_001, true)] {
|
||||
large[15..19].copy_from_slice(&rows.to_be_bytes());
|
||||
let doc = page(20_000, 4, &large, None, false);
|
||||
assert_eq!(skipped(&doc), refused, "a page of 20,000 × {rows}");
|
||||
if !refused {
|
||||
assert_eq!(exported_rgba(&doc).1.len(), 20_000 * 4 * 4);
|
||||
}
|
||||
}
|
||||
// Damaged data never panics.
|
||||
for end in [0, 1, 10, 20, 30, jbig2.len() / 2] {
|
||||
let _ = extract_images(&page(w, h, &jbig2[..end], None, false), &[0], 0);
|
||||
}
|
||||
|
||||
// As a stencil /Mask, its 1 samples (JBIG2 white) hide the image.
|
||||
let alpha: Vec<u8> = picture(h).as_chunks::<4>().0.iter().map(|p| 255 - p[0]).collect();
|
||||
assert_eq!(alphas(&exported_rgba(&page(w, h, &jbig2, None, true)).1), alpha);
|
||||
}
|
||||
|
||||
/// A JBIG2 image's `/JBIG2Globals` are decoded before its JBIG2 page's size is known, so they have
|
||||
/// a limit of their own, 32 MiB, far below the 1 GiB of any stream: globals that Flate-decode to
|
||||
/// one byte more skip the image, with the limit in the reason, though they take a few kilobytes
|
||||
/// in the file. Globals of exactly the limit are read, and the limit is the same through an
|
||||
/// indirect `/DecodeParms`, in an array or not.
|
||||
#[test]
|
||||
fn jbig2_globals_are_decoded_within_a_limit_of_their_own() {
|
||||
const LIMIT: usize = 32 << 20;
|
||||
let bits = [0xF0u8, 0x0F, 0xAA, 0x55];
|
||||
let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap();
|
||||
let picture: Vec<u8> = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255; 4] } else { [0, 0, 0, 255] }).collect();
|
||||
// Globals of `len` bytes, Flate-encoded: one extension segment (ISO/IEC 14492 7.4.14), which a
|
||||
// decoder skips; its 11-byte header gives the length of the data after it.
|
||||
let globals = |len: usize| {
|
||||
let mut g = vec![0, 0, 0, 0, 0x3E, 0, 0];
|
||||
g.extend_from_slice(&u32::try_from(len - 11).unwrap().to_be_bytes());
|
||||
g.extend_from_slice(&[0, 0, 0, 1]);
|
||||
g.resize(len, 0);
|
||||
pdfcraft_filters::encode_flate(&g)
|
||||
};
|
||||
// The image, its /DecodeParms naming `flated` as its globals: direct (0), by reference (1), or
|
||||
// by reference in an array (2).
|
||||
let page = |flated: &[u8], form: usize| {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mut g = Dict::new();
|
||||
g.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
let g = doc.add(Object::Stream(Stream::from_raw(g, flated.to_vec())));
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g));
|
||||
let parms = match form {
|
||||
0 => Object::Dict(parms),
|
||||
1 => Object::Ref(doc.add(Object::Dict(parms))),
|
||||
_ => Object::Array(vec![Object::Ref(doc.add(Object::Dict(parms)))]),
|
||||
};
|
||||
let mut d = image_dict(16, 2, "DeviceGray", 1);
|
||||
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
d.set(b"DecodeParms".to_vec(), parms);
|
||||
Stream::from_raw(d, jbig2.clone())
|
||||
});
|
||||
doc
|
||||
};
|
||||
|
||||
let (small, full, over) = (globals(15), globals(LIMIT), globals(LIMIT + 1));
|
||||
assert!(over.len() < 64 << 10, "the oversized globals take {} bytes in the file", over.len());
|
||||
assert_eq!(exported_rgba(&page(&full, 0)).1, picture, "globals of exactly the limit");
|
||||
for form in 0..3 {
|
||||
assert_eq!(exported_rgba(&page(&small, form)).1, picture, "small globals, form {form}");
|
||||
let out = extract_images(&page(&over, form), &[0], 0);
|
||||
assert!(out.images.is_empty(), "globals over the limit, form {form}");
|
||||
let why = &out.skipped.first().expect("the image is skipped").2;
|
||||
assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}");
|
||||
}
|
||||
}
|
||||
@@ -634,12 +634,20 @@ fn expand(seed: &[u8; 32], label: &[u8], n: usize) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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<SecurityHandler, CryptError> {
|
||||
// Bits 7–8 and 13–32 must be 1; bits 1–2 must be 0 (§7.6.4.2).
|
||||
|
||||
@@ -4,6 +4,16 @@ fn hex(s: &str) -> Vec<u8> {
|
||||
(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"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -67,5 +67,23 @@ impl Content {
|
||||
}
|
||||
}
|
||||
|
||||
fn align_code(a: Align) -> i64 {
|
||||
match a {
|
||||
Align::Left => 0,
|
||||
Align::Center => 1,
|
||||
Align::Right => 2,
|
||||
Align::Justify => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn align_from(code: Option<i64>) -> Align {
|
||||
match code {
|
||||
Some(1) => Align::Center,
|
||||
Some(2) => Align::Right,
|
||||
Some(3) => Align::Justify,
|
||||
_ => Align::Left,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod arabic_tests;
|
||||
@@ -24,7 +24,7 @@ fn cluster(id: u32, advance: f64) -> ShapedCluster {
|
||||
fn line(clusters: Vec<ShapedCluster>, rtl: bool) -> LaidLine {
|
||||
let text = clusters.iter().map(|c| c.text.as_str()).collect();
|
||||
let width = clusters.iter().map(|c| c.advance).sum::<f64>() * item().size;
|
||||
(text, rtl, vec![Piece::Shaped(clusters)], width)
|
||||
(text, rtl, 0, vec![Piece::Shaped(clusters)], width)
|
||||
}
|
||||
|
||||
/// A glyph outline: a box on the baseline, `advance` em wide.
|
||||
@@ -147,7 +147,7 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() {
|
||||
let space = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(6, [0.0, 0.0])], advance: 0.25, text: " ".into() };
|
||||
let ligature = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(4, [0.0, 0.0]), (5, [-0.1, 0.2])], advance: 0.6, text: "لا".into() };
|
||||
let mixed: LaidLine =
|
||||
("Hi ب".into(), false, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
|
||||
("Hi ب".into(), false, 0, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
|
||||
let rtl = line(vec![cluster(2, 0.5), cluster(9, 0.75)], true);
|
||||
let laid = vec![mixed, rtl];
|
||||
let mut doc = Document::new_empty();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::layout::{
|
||||
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped,
|
||||
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, norm, shaped_layout, text_rect, thai_error, wrapped,
|
||||
};
|
||||
use super::types::{AddedText, Align, Family};
|
||||
use super::{
|
||||
Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream,
|
||||
literal, matrix_number, n, shaped_glyph, win_ansi,
|
||||
align_code, literal, matrix_number, n, shaped_glyph, win_ansi,
|
||||
};
|
||||
|
||||
fn font_name(base: &str) -> String {
|
||||
@@ -16,8 +16,26 @@ fn font_name(base: &str) -> String {
|
||||
pub(super) const GLYPHS_PER_FONT: usize = 240;
|
||||
|
||||
/// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left,
|
||||
/// its pieces in drawing order and its width at the item's size.
|
||||
pub(super) type LaidLine = (String, bool, Vec<Piece>, f64);
|
||||
/// its paragraph, its pieces in drawing order and its width at the item's size.
|
||||
pub(super) type LaidLine = (String, bool, usize, Vec<Piece>, f64);
|
||||
|
||||
/// Where line `i` (`w` wide unstretched, with `spaces` spaces) starts in the box `r`, and its word
|
||||
/// spacing. `paragraphs` holds each line's paragraph: a line takes its paragraph's alignment, and
|
||||
/// every line of a justified paragraph but its last is stretched to the box's width.
|
||||
fn placed(t: &AddedText, r: [f64; 4], paragraphs: &[usize], i: usize, w: f64, spaces: usize, rtl: bool) -> (f64, f64) {
|
||||
let paragraph = paragraphs.get(i).copied().unwrap_or(0);
|
||||
let align = t.paragraph_align(paragraph);
|
||||
let ends_paragraph = paragraphs.get(i + 1) != Some(¶graph);
|
||||
let tw = if align == Align::Justify && !ends_paragraph && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
|
||||
let x = match align {
|
||||
// A right-to-left paragraph's unstretched last line ends at the right edge.
|
||||
Align::Justify if rtl => r[2] - w - tw * spaces as f64,
|
||||
Align::Left | Align::Justify => r[0],
|
||||
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
|
||||
Align::Right => r[2] - w,
|
||||
};
|
||||
(x, tw)
|
||||
}
|
||||
|
||||
/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs
|
||||
/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded),
|
||||
@@ -25,7 +43,7 @@ pub(super) type LaidLine = (String, bool, Vec<Piece>, f64);
|
||||
fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
|
||||
let laid = wrapped(t)
|
||||
.into_iter()
|
||||
.map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w)))
|
||||
.map(|(line, rtl, paragraph)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, paragraph, pieces, w)))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
draw_laid_shaped(doc, t, &laid, taken, fonts, out, shaped_glyph)
|
||||
}
|
||||
@@ -47,7 +65,7 @@ pub(super) fn draw_laid_shaped(
|
||||
// one of the fonts, so copy and search get each character exactly once.
|
||||
let mut keys: Vec<&ShapedCluster> = Vec::new();
|
||||
let mut index: HashMap<ClusterKey, usize> = HashMap::new();
|
||||
for c in laid.iter().flat_map(|(_, _, pieces, _)| pieces).flat_map(|p| match p {
|
||||
for c in laid.iter().flat_map(|(_, _, _, pieces, _)| pieces).flat_map(|p| match p {
|
||||
Piece::Shaped(clusters) => clusters.as_slice(),
|
||||
Piece::Latin(_) => &[],
|
||||
}) {
|
||||
@@ -92,21 +110,14 @@ pub(super) fn draw_laid_shaped(
|
||||
if t.char_spacing != 0.0 {
|
||||
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
|
||||
}
|
||||
for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() {
|
||||
let paragraphs: Vec<usize> = laid.iter().map(|(_, _, p, ..)| *p).collect();
|
||||
for (i, (line, rtl, _, pieces, w)) in laid.iter().enumerate() {
|
||||
// Shaped glyphs are painted in visual order. Keep the original line's typing order
|
||||
// explicitly, including marks, ligatures, digits and mixed-script punctuation.
|
||||
let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect();
|
||||
out.extend(format!("/Span <</ActualText <FEFF{logical}>>> BDC\n").bytes());
|
||||
let spaces = line.matches(' ').count();
|
||||
// Justified: the spaces of every line but the last stretch to fill the box.
|
||||
let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
|
||||
let x = match t.align {
|
||||
// A right-to-left paragraph's unstretched last line ends at the right edge.
|
||||
Align::Justify if *rtl => r[2] - w - tw * spaces as f64,
|
||||
Align::Left | Align::Justify => r[0],
|
||||
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
|
||||
Align::Right => r[2] - w,
|
||||
};
|
||||
let (x, tw) = placed(t, r, ¶graphs, i, *w, spaces, *rtl);
|
||||
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
|
||||
let mut pen = x;
|
||||
let mut current: Option<&str> = None;
|
||||
@@ -287,20 +298,14 @@ pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict
|
||||
if t.char_spacing != 0.0 {
|
||||
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
|
||||
}
|
||||
let all = lines(t);
|
||||
for (i, line) in all.iter().enumerate() {
|
||||
let all = wrapped(t);
|
||||
let paragraphs: Vec<usize> = all.iter().map(|(_, _, p)| *p).collect();
|
||||
for (i, (line, ..)) in all.iter().enumerate() {
|
||||
let w = latin_width(t, line);
|
||||
let x = match t.align {
|
||||
Align::Left | Align::Justify => r[0],
|
||||
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
|
||||
Align::Right => r[2] - w,
|
||||
};
|
||||
// Baseline: 0.8 em below the line top.
|
||||
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
|
||||
// Justified: word spacing makes every line but the last fill the box.
|
||||
let spaces = line.matches(' ').count();
|
||||
let tw =
|
||||
if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
|
||||
// Justified: word spacing makes every line of a paragraph but its last fill the box.
|
||||
let (x, tw) = placed(t, r, ¶graphs, i, w, line.matches(' ').count(), false);
|
||||
out.extend(format!("1 0 0 1 {} {} Tm {} Tw ", n(x), n(y), n(tw)).bytes());
|
||||
out.extend(literal(&win_ansi(line)));
|
||||
out.extend_from_slice(b" Tj\n");
|
||||
@@ -350,15 +355,12 @@ pub(super) fn params(c: &Content) -> Dict {
|
||||
d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic)));
|
||||
d.set(b"Size".to_vec(), Object::Real(t.size));
|
||||
d.set(b"Color".to_vec(), arr(&t.color));
|
||||
d.set(
|
||||
b"Align".to_vec(),
|
||||
Object::Int(match t.align {
|
||||
Align::Left => 0,
|
||||
Align::Center => 1,
|
||||
Align::Right => 2,
|
||||
Align::Justify => 3,
|
||||
}),
|
||||
);
|
||||
d.set(b"Align".to_vec(), Object::Int(align_code(t.align)));
|
||||
// Each paragraph's own alignment, or null where it follows /Align.
|
||||
let aligns = crate::paragraph_align::tidy(t.aligns.clone(), t.align);
|
||||
if !aligns.is_empty() {
|
||||
d.set(b"Aligns".to_vec(), Object::Array(aligns.iter().map(|a| a.map_or(Object::Null, |a| Object::Int(align_code(a)))).collect()));
|
||||
}
|
||||
d.set(b"Rect".to_vec(), arr(&text_rect(t)));
|
||||
if t.char_spacing != 0.0 {
|
||||
d.set(b"CharSpacing".to_vec(), Object::Real(t.char_spacing));
|
||||
|
||||
@@ -20,7 +20,7 @@ pub(super) fn latin_width(t: &AddedText, s: &str) -> f64 {
|
||||
|
||||
/// The lines of a text item after wrapping to its box width.
|
||||
pub fn lines(t: &AddedText) -> Vec<String> {
|
||||
wrapped(t).into_iter().map(|(line, _)| line).collect()
|
||||
wrapped(t).into_iter().map(|(line, ..)| line).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -30,13 +30,13 @@ thread_local! {
|
||||
}
|
||||
|
||||
/// [`lines`], each with whether its paragraph runs right to left (its first strong character
|
||||
/// does).
|
||||
pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
|
||||
/// does) and which paragraph it is in.
|
||||
pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> {
|
||||
#[cfg(test)]
|
||||
WRAP_CALLS.with(|c| c.set(c.get().saturating_add(1)));
|
||||
let width = (t.rect[2] - t.rect[0]).max(t.size);
|
||||
let mut out = Vec::new();
|
||||
for para in t.text.split('\n') {
|
||||
for (index, para) in t.text.split('\n').enumerate() {
|
||||
// Arabic or Hebrew: laid out with the bidirectional algorithm and shaped.
|
||||
let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c));
|
||||
let thai = para.chars().any(is_thai);
|
||||
@@ -44,7 +44,7 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
|
||||
&& !rtl_script
|
||||
&& let Ok(lines) = thai_lines(t, para, width)
|
||||
{
|
||||
out.extend(lines.into_iter().map(|line| (line, false)));
|
||||
out.extend(lines.into_iter().map(|line| (line, false, index)));
|
||||
continue;
|
||||
}
|
||||
let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
|
||||
@@ -60,7 +60,7 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
|
||||
let candidate_w =
|
||||
if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
|
||||
if !line.is_empty() && candidate_w > width {
|
||||
out.push((std::mem::take(&mut line), rtl));
|
||||
out.push((std::mem::take(&mut line), rtl, index));
|
||||
line = word.to_string();
|
||||
line_w = measure(word);
|
||||
} else {
|
||||
@@ -68,7 +68,7 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
|
||||
line_w = candidate_w;
|
||||
}
|
||||
}
|
||||
out.push((line, rtl));
|
||||
out.push((line, rtl, index));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -142,7 +142,7 @@ pub fn first_undrawable(t: &AddedText) -> Option<char> {
|
||||
return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi);
|
||||
}
|
||||
// What `shaped_layout` leaves to the standard font, exactly as `draw_shaped` splits it.
|
||||
wrapped(t).into_iter().find_map(|(line, rtl)| {
|
||||
wrapped(t).into_iter().find_map(|(line, rtl, _)| {
|
||||
let (pieces, _) = shaped_layout(t, &line, rtl).ok()?;
|
||||
pieces.iter().find_map(|p| match p {
|
||||
Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::draw::{draw, params};
|
||||
use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error};
|
||||
use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
|
||||
use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, check, contents, page_list, place_tagged};
|
||||
use super::types::{Added, AddedImage, AddedText, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
|
||||
use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, align_from, check, contents, page_list, place_tagged};
|
||||
|
||||
fn parse(doc: &Document, d: &Dict) -> Option<Content> {
|
||||
let r = nums(doc, d.get(b"Rect"));
|
||||
@@ -10,20 +10,27 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> {
|
||||
b"Text" => {
|
||||
let (family, bold, italic) = Family::from_base(d.name(b"Font").unwrap_or(b"Helvetica"));
|
||||
let c = nums(doc, d.get(b"Color"));
|
||||
let text = d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default();
|
||||
let align = align_from(d.int(b"Align"));
|
||||
// At most one entry per paragraph, whatever the file says.
|
||||
let aligns = d
|
||||
.get(b"Aligns")
|
||||
.and_then(|a| {
|
||||
doc.resolve(a)
|
||||
.as_array()
|
||||
.map(|a| a.iter().take(crate::paragraph_align::count(&text)).map(|v| v.as_int().map(|c| align_from(Some(c)))).collect())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(Content::Text(AddedText {
|
||||
rect,
|
||||
text: d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(),
|
||||
text,
|
||||
family,
|
||||
bold,
|
||||
italic,
|
||||
size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0),
|
||||
color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] },
|
||||
align: match d.int(b"Align") {
|
||||
Some(1) => Align::Center,
|
||||
Some(2) => Align::Right,
|
||||
Some(3) => Align::Justify,
|
||||
_ => Align::Left,
|
||||
},
|
||||
align,
|
||||
aligns: crate::paragraph_align::tidy(aligns, align),
|
||||
// Items written before character spacing have none.
|
||||
char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0),
|
||||
}))
|
||||
|
||||
@@ -104,6 +104,16 @@ pub struct AddedText {
|
||||
pub align: Align,
|
||||
/// Extra space after every character, in points: the PDF's character spacing (`Tc`).
|
||||
pub char_spacing: f64,
|
||||
/// Paragraphs (lines typed with Enter) aligned other than [`AddedText::align`], by index;
|
||||
/// `None` and paragraphs past the end follow `align` ([`crate::paragraph_align`]).
|
||||
pub aligns: Vec<Option<Align>>,
|
||||
}
|
||||
|
||||
impl AddedText {
|
||||
/// Paragraph `i`'s alignment.
|
||||
pub fn paragraph_align(&self, i: usize) -> Align {
|
||||
crate::paragraph_align::align_of(&self.aligns, i, self.align)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AddedText {
|
||||
@@ -117,6 +127,7 @@ impl Default for AddedText {
|
||||
size: 12.0,
|
||||
color: [0.0; 3],
|
||||
align: Align::Left,
|
||||
aligns: Vec::new(),
|
||||
char_spacing: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<usize, EditError> {
|
||||
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<Object> = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect();
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
//! 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 <SHA-256> /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::{DecodeBudget, 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<usize>,
|
||||
op: std::ops::Range<usize>,
|
||||
}
|
||||
|
||||
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<Option<Backdrop>, 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<Option<Backdrop>> {
|
||||
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<usize> {
|
||||
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<Object> = 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<bool, EditError> {
|
||||
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)));
|
||||
// scan accepts only one execution of this stream on the page. Other pages keep the
|
||||
// original stream; ambiguous repeated executions are never rewritten.
|
||||
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<usize, EditError> {
|
||||
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<ObjRef>)> = 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())
|
||||
&& DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).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<u8>, Dict, Option<ObjRef>)> {
|
||||
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 = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).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<u8> {
|
||||
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<Found>)> {
|
||||
let crop = p.crop(doc);
|
||||
let c = p.dict.get(b"Contents")?;
|
||||
let list: Vec<Object> = match &*doc.resolve(c) {
|
||||
Object::Array(a) if a.len() <= SCAN_OPS => a.clone(),
|
||||
Object::Array(_) => return None,
|
||||
_ => 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;
|
||||
let mut budget = DecodeBudget::new(SCAN_BYTES, 0);
|
||||
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 => {
|
||||
if b.len() > SCAN_BYTES.saturating_sub(total) {
|
||||
return None;
|
||||
}
|
||||
budget.decode_strict(&Stream::from_raw(Dict::new(), b.to_vec())).ok()?
|
||||
}
|
||||
// Tolerant decoding can return an empty or partial stream after corruption.
|
||||
// That cannot prove which paint preceded the candidate backdrop.
|
||||
_ => budget.decode_strict(s).ok()?,
|
||||
};
|
||||
if bytes.len() > SCAN_BYTES.saturating_sub(total).saturating_sub(1) {
|
||||
return None;
|
||||
}
|
||||
starts.push(total);
|
||||
total += bytes.len() + 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);
|
||||
// A match proves the graphics state for one execution only. Repeated references may
|
||||
// paint real artwork under a different color or transform: leave them untouched.
|
||||
if let Some((Backdrop::Fill, Some(op))) = &found {
|
||||
let piece = pieces.piece_of(op.start);
|
||||
let (_, reference) = refs.get(piece)?;
|
||||
if list.iter().filter(|o| matches!(o, Object::Ref(r) if r == reference)).count() != 1 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
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<std::sync::Arc<Object>> {
|
||||
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<std::ops::Range<usize>>)> {
|
||||
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<State> = Vec::new();
|
||||
// Current path: its rectangles in user space, or `None` once it is anything else.
|
||||
let mut path: Option<Vec<[f64; 4]>> = 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<f64> = 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;
|
||||
@@ -0,0 +1,607 @@
|
||||
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<String> = (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<String> {
|
||||
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::<usize>::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::<usize>::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::<usize>::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::<String>();
|
||||
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<String> = (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<usize> = (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<Object> = 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<Object> = pdfcraft_model::pages(&doc).iter().filter_map(|p| p.dict.get(b"Contents").cloned()).collect();
|
||||
let refs = |list: &[Object]| -> Vec<Vec<Object>> {
|
||||
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_left_untouched_as_ambiguous() {
|
||||
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_eq!(s[1..], original[..], "repeated executions are not qualified independently");
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undecodable_stream_before_a_white_fill_keeps_the_backdrop_unknown() {
|
||||
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 5 0 R] >>".into(),
|
||||
stream("/Filter /FlateDecode", "not a deflate stream"),
|
||||
stream("", &format!("1 g 0 0 300 200 re f {TEXT}")),
|
||||
];
|
||||
let doc = build(&objs);
|
||||
assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "unknown earlier paint must not be treated as an empty stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_fill_used_as_later_artwork_is_not_rewritten() {
|
||||
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 5 0 R 6 0 R 5 0 R 7 0 R] >>".into(),
|
||||
stream("", "1 g"),
|
||||
stream("", "0 0 300 200 re f"),
|
||||
stream("", "q 0 g 0.25 0 0 0.25 20 20 cm"),
|
||||
stream("", "Q"),
|
||||
]);
|
||||
assert_eq!(page_backdrop(&doc, 0).unwrap(), None);
|
||||
let original = streams(&doc);
|
||||
add_background(&mut doc, &[0], &red(), false).unwrap();
|
||||
assert_eq!(streams(&doc)[1..], original[..], "the later black artwork keeps its fill");
|
||||
let mut saved = reopen(&doc);
|
||||
assert_eq!(streams(&saved)[1..], original[..]);
|
||||
remove_marks(&mut saved, &[0], MarkKind::Background).unwrap();
|
||||
assert_eq!(streams(&saved), original);
|
||||
assert_eq!(streams(&reopen(&saved)), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_backdrop_content_is_refused_before_expanding_past_the_scan_budget() {
|
||||
let mut doc = page(&["1 g 0 0 300 200 re f"]);
|
||||
let mut bytes = b"1 g 0 0 300 200 re f\n".to_vec();
|
||||
bytes.resize(2 * 1024 * 1024, b' ');
|
||||
let source = doc.add(Object::Stream(Stream::flate(Dict::new(), &bytes)));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(source))).unwrap();
|
||||
assert_eq!(page_backdrop(&doc, 0).unwrap(), None);
|
||||
add_background(&mut doc, &[0], &red(), false).unwrap();
|
||||
let Object::Stream(unchanged) = &*doc.get(source) else { panic!("stream") };
|
||||
assert_eq!(unchanged.decoded_strict().unwrap(), bytes, "refusal preserves the source");
|
||||
}
|
||||
@@ -186,11 +186,10 @@ impl ImageState {
|
||||
self.path.curve_to(self.point(x1, y1), end, end);
|
||||
}
|
||||
}
|
||||
b"h"
|
||||
// ClosePath with no subpath panics by the same contract (#3581).
|
||||
if self.path.elements().last().is_some() => {
|
||||
self.path.close_path();
|
||||
}
|
||||
// ClosePath with no subpath panics by the same contract (#3581).
|
||||
b"h" if self.path.elements().last().is_some() => {
|
||||
self.path.close_path();
|
||||
}
|
||||
b"re" => {
|
||||
if let Some([x, y, w, h]) = op.nums::<4>() {
|
||||
self.path.move_to(self.point(x, y));
|
||||
|
||||
@@ -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;
|
||||
@@ -45,6 +47,7 @@ pub use images::{ImageChange, PageImage, change_image, page_images, reading_imag
|
||||
pub mod bidi;
|
||||
pub mod indic;
|
||||
mod linebreak;
|
||||
pub mod paragraph_align;
|
||||
pub mod text;
|
||||
pub use text::{
|
||||
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
|
||||
|
||||
@@ -173,11 +173,17 @@ pub struct Background {
|
||||
pub source: Option<MarkSource>,
|
||||
/// 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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Alignment paragraph by paragraph. A paragraph is the text between two hard line breaks
|
||||
//! (`\n`): a line typed with Enter in added text, or a source row of an edited paragraph. Each can
|
||||
//! have an alignment of its own; `None` (or no entry) follows the box's or paragraph's alignment.
|
||||
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
use crate::added::Align;
|
||||
|
||||
/// How many paragraphs `text` has (an empty text has one).
|
||||
pub fn count(text: &str) -> usize {
|
||||
text.bytes().filter(|b| *b == b'\n').count().saturating_add(1)
|
||||
}
|
||||
|
||||
/// The paragraph the byte position `at` of `text` is in.
|
||||
fn paragraph_at(text: &str, at: usize) -> usize {
|
||||
let bytes = text.as_bytes();
|
||||
bytes.get(..at.min(bytes.len())).unwrap_or_default().iter().filter(|b| **b == b'\n').count()
|
||||
}
|
||||
|
||||
/// The paragraphs a selection (byte range) of `text` touches. An empty selection is the caret's
|
||||
/// paragraph; a selection ending just after a line break doesn't reach the next paragraph.
|
||||
pub fn selected(text: &str, range: std::ops::Range<usize>) -> RangeInclusive<usize> {
|
||||
let first = paragraph_at(text, range.start);
|
||||
let last = if range.end > range.start { paragraph_at(text, range.end - 1) } else { first };
|
||||
first..=last.max(first)
|
||||
}
|
||||
|
||||
/// Paragraph `i`'s alignment: its own, or `base`.
|
||||
pub fn align_of(aligns: &[Option<Align>], i: usize, base: Align) -> Align {
|
||||
aligns.get(i).copied().flatten().unwrap_or(base)
|
||||
}
|
||||
|
||||
/// Give paragraphs `which` of a text with `count` paragraphs the alignment `align`; the others
|
||||
/// keep theirs. With a `base`, entries equal to it become `None` ([`tidy`]), so equal alignments
|
||||
/// compare equal however they were set; without one (the base is only guessed, as for an edited
|
||||
/// paragraph's own rows) every entry set stays. Trailing `None`s are dropped either way.
|
||||
pub fn set(aligns: &[Option<Align>], count: usize, which: RangeInclusive<usize>, align: Align, base: Option<Align>) -> Vec<Option<Align>> {
|
||||
let mut out: Vec<Option<Align>> = (0..count).map(|i| aligns.get(i).copied().flatten()).collect();
|
||||
for i in which {
|
||||
if let Some(a) = out.get_mut(i) {
|
||||
*a = Some(align);
|
||||
}
|
||||
}
|
||||
match base {
|
||||
Some(base) => tidy(out, base),
|
||||
None => trimmed(out),
|
||||
}
|
||||
}
|
||||
|
||||
/// `aligns` without entries that only repeat `base`, and without trailing `None`s.
|
||||
pub fn tidy(mut aligns: Vec<Option<Align>>, base: Align) -> Vec<Option<Align>> {
|
||||
for a in &mut aligns {
|
||||
if *a == Some(base) {
|
||||
*a = None;
|
||||
}
|
||||
}
|
||||
trimmed(aligns)
|
||||
}
|
||||
|
||||
fn trimmed(mut aligns: Vec<Option<Align>>) -> Vec<Option<Align>> {
|
||||
while aligns.last() == Some(&None) {
|
||||
aligns.pop();
|
||||
}
|
||||
aligns
|
||||
}
|
||||
|
||||
/// Carry the alignments of `old`'s paragraphs over to `new`: the paragraphs before and after
|
||||
/// the change keep theirs, and the paragraphs the change made take the alignment of the first
|
||||
/// paragraph it changed, or for lines only inserted, of the paragraph before them (so Enter in or
|
||||
/// after a centred paragraph makes centred ones).
|
||||
pub fn remap(old: &str, new: &str, aligns: &[Option<Align>]) -> Vec<Option<Align>> {
|
||||
remap_at(old, new, aligns, None)
|
||||
}
|
||||
|
||||
/// [`remap`] for an edit that left the caret at byte `caret` of `new`. Repeated paragraphs can
|
||||
/// make where the edit was ambiguous ("A\nA" to "A" deletes either one); the caret, at the edit's
|
||||
/// end, says which. It only chooses between readings that fit the texts, so a caret away from the
|
||||
/// edit never makes it an edit of other paragraphs.
|
||||
pub fn remap_at(old: &str, new: &str, aligns: &[Option<Align>], caret: Option<usize>) -> Vec<Option<Align>> {
|
||||
if aligns.is_empty() || old == new {
|
||||
return aligns.to_vec();
|
||||
}
|
||||
let (o, n): (Vec<&str>, Vec<&str>) = (old.split('\n').collect(), new.split('\n').collect());
|
||||
let shortest = o.len().min(n.len());
|
||||
let same_start = o.iter().zip(&n).take_while(|(a, b)| a == b).count().min(shortest);
|
||||
let same_end = o.iter().rev().zip(n.iter().rev()).take_while(|(a, b)| a == b).count().min(shortest);
|
||||
let fewest_kept = shortest - same_start;
|
||||
let (prefix, suffix) = match caret {
|
||||
Some(at) if fewest_kept <= same_end => {
|
||||
// The edit's paragraphs of `new` end before the caret's paragraph when the caret starts
|
||||
// one, and with it otherwise.
|
||||
let starts = at == 0 || at.checked_sub(1).and_then(|i| new.as_bytes().get(i)) == Some(&b'\n');
|
||||
let end = paragraph_at(new, at).saturating_add(usize::from(!starts));
|
||||
let suffix = n.len().saturating_sub(end).clamp(fewest_kept, same_end);
|
||||
(shortest - suffix, suffix)
|
||||
}
|
||||
_ => (same_start, same_end.min(shortest - same_start)),
|
||||
};
|
||||
let at = |i: usize| aligns.get(i).copied().flatten();
|
||||
let inserted_only = o.len() - prefix - suffix == 0;
|
||||
let changed = if inserted_only && prefix > 0 { at(prefix - 1) } else { at(prefix) };
|
||||
let mut out: Vec<Option<Align>> = (0..prefix).map(at).collect();
|
||||
out.extend(std::iter::repeat_n(changed, n.len() - prefix - suffix));
|
||||
out.extend((o.len() - suffix..o.len()).map(at));
|
||||
trimmed(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use Align::*;
|
||||
|
||||
#[test]
|
||||
fn a_selection_names_the_paragraphs_it_touches() {
|
||||
let text = "One\nTwo\nThree";
|
||||
assert_eq!(count(text), 3);
|
||||
assert_eq!(count(""), 1);
|
||||
assert_eq!(selected(text, 0..0), 0..=0, "the caret's paragraph");
|
||||
assert_eq!(selected(text, 5..6), 1..=1);
|
||||
assert_eq!(selected(text, 2..9), 0..=2);
|
||||
assert_eq!(selected(text, 4..8), 1..=1, "a whole line with its break stays on that line");
|
||||
assert_eq!(selected(text, 0..999), 0..=2, "past the end is clamped");
|
||||
// Not a character boundary: still counted by bytes, never a panic.
|
||||
assert_eq!(selected("é\nb", 1..1), 0..=0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_one_paragraph_keeps_the_others_and_tidies() {
|
||||
let one = set(&[], 3, 1..=1, Center, Some(Left));
|
||||
assert_eq!(one, [None, Some(Center)]);
|
||||
assert_eq!(align_of(&one, 0, Left), Left);
|
||||
assert_eq!(align_of(&one, 1, Left), Center);
|
||||
assert_eq!(align_of(&one, 9, Right), Right);
|
||||
assert_eq!(set(&one, 3, 1..=1, Left, Some(Left)), [], "back to the box's alignment is no entry at all");
|
||||
assert_eq!(set(&one, 3, 2..=7, Right, Some(Left)), [None, Some(Center), Some(Right)], "paragraphs past the end are ignored");
|
||||
assert_eq!(set(&[Some(Right); 9], 2, 0..=0, Center, Some(Left)), [Some(Center), Some(Right)], "entries past the text are dropped");
|
||||
assert_eq!(set(&[], 3, 0..=0, Left, None), [Some(Left)], "without a base, an explicit entry stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignments_follow_their_paragraphs_through_an_edit() {
|
||||
let aligns = [Some(Center), None, Some(Right)];
|
||||
assert_eq!(remap("A\nB\nC", "A\nB\nC", &aligns), aligns);
|
||||
// A line inserted before the last keeps the last right-aligned.
|
||||
assert_eq!(remap("A\nB\nC", "A\nB\nnew\nC", &aligns), [Some(Center), None, None, Some(Right)]);
|
||||
// Enter inside the centred first paragraph: both halves are centred.
|
||||
assert_eq!(remap("Ab\nB\nC", "A\nb\nB\nC", &aligns), [Some(Center), Some(Center), None, Some(Right)]);
|
||||
// The middle paragraph deleted.
|
||||
assert_eq!(remap("A\nB\nC", "A\nC", &aligns), [Some(Center), Some(Right)]);
|
||||
// Everything replaced takes the first changed paragraph's alignment.
|
||||
assert_eq!(remap("A\nB", "x", &[None, Some(Right)]), []);
|
||||
assert_eq!(remap("A\nA", "A", &[Some(Center), Some(Right)]), [Some(Center)]);
|
||||
assert_eq!(remap("A", "A\nA", &[Some(Center)]), [Some(Center), Some(Center)]);
|
||||
}
|
||||
|
||||
/// Repeated rows make the edit's place ambiguous: "A\nA" to "A" deletes either row. The caret,
|
||||
/// where the edit ended, settles which (CodeRabbit on 648dd428).
|
||||
#[test]
|
||||
fn the_caret_says_which_of_two_same_rows_an_edit_took() {
|
||||
let aligns = [Some(Center), Some(Right)];
|
||||
assert_eq!(remap_at("A\nA", "A", &aligns, Some(0)), [Some(Right)], "the first row and its break deleted");
|
||||
assert_eq!(remap_at("A\nA", "A", &aligns, Some(1)), [Some(Center)], "the break and the second row deleted");
|
||||
assert_eq!(remap_at("A\nA", "A", &aligns, None), [Some(Center)], "no caret: as before");
|
||||
// Enter at the start of the first of two same rows: the new row is before it.
|
||||
assert_eq!(remap_at("A\nA", "\nA\nA", &aligns, Some(1)), [Some(Center), Some(Center), Some(Right)]);
|
||||
// Enter at the end of the second: the new row follows it.
|
||||
assert_eq!(remap_at("A\nA", "A\nA\n", &aligns, Some(4)), [Some(Center), Some(Right), Some(Right)]);
|
||||
// A caret away from the edit never makes it an edit of other rows.
|
||||
assert_eq!(remap_at("A\nB\nC", "A\nB\nCx", &[Some(Center), Some(Right), None], Some(0)), [Some(Center), Some(Right)]);
|
||||
// A caret past the end, or not on a character boundary, is no panic.
|
||||
assert_eq!(remap_at("A\nA", "A", &aligns, Some(99)), [Some(Center)]);
|
||||
assert_eq!(remap_at("é\né", "é", &aligns, Some(1)), [Some(Center)]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+49
-18
@@ -24,7 +24,7 @@ use crate::EditError;
|
||||
mod regions;
|
||||
#[path = "text_session.rs"]
|
||||
mod session;
|
||||
use regions::{group_blocks, is_centred, rewrite_styled_block};
|
||||
use regions::{alignment_frame, group_blocks, is_centred, rewrite_styled_block};
|
||||
pub use session::TextSession;
|
||||
|
||||
mod blocks;
|
||||
@@ -118,6 +118,14 @@ pub struct TextLine {
|
||||
/// `ink`, and whether joining put a space before it. Empty when the line's text was put
|
||||
/// into another order (see [`TextLine::runs`]).
|
||||
runs: Vec<(String, usize, bool)>,
|
||||
/// This line continues the previous one because a rewrite wrapped it (`/PCSoft`).
|
||||
soft_break: bool,
|
||||
/// This line starts after a break a rewrite kept on purpose (`/PCHard`).
|
||||
hard_break: bool,
|
||||
/// When `soft_break`: the exact number of separator characters the wrap consumed (0 for a
|
||||
/// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none
|
||||
/// (#2529). Meaningless otherwise.
|
||||
soft_gap: usize,
|
||||
}
|
||||
|
||||
impl TextLine {
|
||||
@@ -154,6 +162,7 @@ mod tests {
|
||||
lines: Vec::new(),
|
||||
rows: Vec::new(),
|
||||
editor_text: text.into(),
|
||||
reflow_text: text.into(),
|
||||
spans: Vec::new(),
|
||||
invisible: false,
|
||||
}
|
||||
@@ -204,40 +213,62 @@ mod tests {
|
||||
assert!(!same_base_font("Arial", ""));
|
||||
}
|
||||
|
||||
fn wrapped(text: &str, width: f64) -> Vec<String> {
|
||||
wrap(text, width, |s| s.chars().count() as f64).into_iter().map(|(line, _, _)| line).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_wrapping_keeps_ideographic_spaces() {
|
||||
assert_eq!(wrapped("A B C", 4.0), ["A B", "C"]);
|
||||
assert_eq!(wrapped("A\u{a0}B C", 3.0), ["A B", "C"]);
|
||||
assert_eq!(wrapped("A\u{2028}B C", 3.0), ["A B", "C"]);
|
||||
// `wrap` treats every whitespace character, including `\r`, as a separator to keep
|
||||
// (#4199); the caller turns `\r\n` and `\r` into `\n` before calling it.
|
||||
assert_eq!(wrapped(" \tA B\r\nC ", 3.0), [" A", "B ", "C "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrap_is_marked_soft_and_a_newline_is_not() {
|
||||
let width = |s: &str| s.chars().count() as f64;
|
||||
assert_eq!(wrap("A B C", 4.0, width), ["A B", "C"]);
|
||||
assert_eq!(wrap("A\u{a0}B C", 3.0, width), ["A B", "C"]);
|
||||
assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]);
|
||||
assert_eq!(wrap("Page 1", 5.0, width), [("Page".into(), false, 0), ("1".into(), true, 1)]);
|
||||
assert_eq!(wrap("One\nTwo", 10.0, width), [("One".into(), false, 0), ("Two".into(), false, 0)]);
|
||||
}
|
||||
|
||||
/// #2529: a run of spaces that happens to fall at a wrap point is recorded exactly, not
|
||||
/// assumed to be one space; a Chinese/Japanese word split, which has no space of its own,
|
||||
/// is recorded as none.
|
||||
#[test]
|
||||
fn a_wrap_records_how_many_spaces_it_consumed() {
|
||||
let width = |s: &str| s.chars().count() as f64;
|
||||
assert_eq!(wrap("One Two", 5.0, width), [("One".into(), false, 0), ("Two".into(), true, 2)]);
|
||||
assert_eq!(wrap("One Two", 5.0, width), [("One".into(), false, 0), ("Two".into(), true, 3)]);
|
||||
assert_eq!(wrap("日本語の文章です", 3.0, width), [("日本語".into(), false, 0), ("の文章".into(), true, 0), ("です".into(), true, 0)]);
|
||||
}
|
||||
|
||||
/// #4199: a run of spaces stays a run, before, between and after words; the spaces where a
|
||||
/// row wraps go, and each other white-space character is one space.
|
||||
#[test]
|
||||
fn paragraph_wrapping_keeps_every_space_typed() {
|
||||
let width = |s: &str| s.chars().count() as f64;
|
||||
assert_eq!(wrap("a b c", 20.0, width), ["a b c"]);
|
||||
assert_eq!(wrap(" a b ", 20.0, width), [" a b "]);
|
||||
assert_eq!(wrap("a\t\tb", 20.0, width), ["a b"]);
|
||||
assert_eq!(wrap("aa bb cc", 6.0, width), ["aa bb", "cc"]);
|
||||
assert_eq!(wrapped("a b c", 20.0), ["a b c"]);
|
||||
assert_eq!(wrapped(" a b ", 20.0), [" a b "]);
|
||||
assert_eq!(wrapped("a\t\tb", 20.0), ["a b"]);
|
||||
assert_eq!(wrapped("aa bb cc", 6.0), ["aa bb", "cc"]);
|
||||
// An indent alone on a row doesn't wrap before its first word.
|
||||
assert_eq!(wrap(" abc", 3.0, width), [" abc"]);
|
||||
assert_eq!(wrapped(" abc", 3.0), [" abc"]);
|
||||
// The rewrite turns `\r\n` and `\r` into `\n` before wrapping.
|
||||
assert_eq!(wrap(" \tA B\nC ", 3.0, width), [" A", "B", "C "]);
|
||||
assert_eq!(wrap(" ", 3.0, width), [" "]);
|
||||
assert_eq!(wrap("", 3.0, width), [""]);
|
||||
assert_eq!(wrapped(" \tA B\nC ", 3.0), [" A", "B", "C "]);
|
||||
assert_eq!(wrapped(" ", 3.0), [" "]);
|
||||
assert_eq!(wrapped("", 3.0), [""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_text_wraps_between_characters_but_not_before_closing_marks() {
|
||||
let width = |s: &str| s.chars().count() as f64;
|
||||
assert_eq!(wrap("日本語の文章です。", 3.0, width), ["日本語", "の文章", "です。"]);
|
||||
assert_eq!(wrapped("日本語の文章です。", 3.0), ["日本語", "の文章", "です。"]);
|
||||
// The comma can't begin a row, so the character before it moves down with it.
|
||||
assert_eq!(wrap("あいう、えお", 3.0, width), ["あい", "う、え", "お"]);
|
||||
assert_eq!(wrapped("あいう、えお", 3.0), ["あい", "う、え", "お"]);
|
||||
// Pieces of one word join without a space; separate words keep theirs.
|
||||
assert_eq!(wrap("Edit PDFを編集", 6.0, width), ["Edit", "PDFを編集"]);
|
||||
assert_eq!(wrap("一行目 二行目", 4.0, width), ["一行目 ", "二行目"]);
|
||||
assert_eq!(wrapped("Edit PDFを編集", 6.0), ["Edit", "PDFを編集"]);
|
||||
assert_eq!(wrapped("一行目 二行目", 4.0), ["一行目 ", "二行目"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -25,6 +25,9 @@ pub struct TextBlock {
|
||||
pub rows: Vec<Vec<usize>>,
|
||||
/// Text for the inline editor, with a newline only between visual rows.
|
||||
pub editor_text: String,
|
||||
/// What a width change reflows. A wrap rejoins with a space; a break that was typed, or a
|
||||
/// short line that still had room for the next word, stays a newline.
|
||||
pub reflow_text: String,
|
||||
/// Byte ranges in `editor_text` and their source line indexes.
|
||||
pub spans: Vec<(std::ops::Range<usize>, usize)>,
|
||||
/// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose
|
||||
@@ -337,11 +340,18 @@ pub(crate) fn paragraph_separator(c: char) -> bool {
|
||||
/// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]);
|
||||
/// those pieces join without a space. Every white-space character is written as one space, so
|
||||
/// a run of spaces typed between, before or after words stays (#4199); only the spaces where a
|
||||
/// row wraps are dropped.
|
||||
pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<String> {
|
||||
/// row wraps are dropped (recorded as the third value, so a later rejoin can restore the exact
|
||||
/// count instead of guessing one, #2529). The bool is true when the line continues the previous
|
||||
/// one because the next word did not fit, rather than because of a newline; the number is how
|
||||
/// many separator characters stood where that wrap fell (0 for a Chinese/Japanese word split,
|
||||
/// which has no space of its own; meaningless when the bool is false).
|
||||
pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<(String, bool, usize)> {
|
||||
let mut out = Vec::new();
|
||||
for row in text.split('\n') {
|
||||
let mut line = String::new();
|
||||
let mut soft = false;
|
||||
// The gap consumed right before `line` started: 0 until a wrap sets it.
|
||||
let mut wrap_gap = 0usize;
|
||||
let start = out.len();
|
||||
// The separators read since the last word: each one boundary between `split` pieces.
|
||||
let mut gap = 0usize;
|
||||
@@ -352,12 +362,17 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec
|
||||
if word.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let spaces = " ".repeat(std::mem::take(&mut gap));
|
||||
let gap_here = std::mem::take(&mut gap);
|
||||
let spaces = " ".repeat(gap_here);
|
||||
for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() {
|
||||
let candidate = if i == 0 { format!("{line}{spaces}{piece}") } else { format!("{line}{piece}") };
|
||||
// A row holding only the spaces that indent it doesn't wrap before its first word.
|
||||
if line.chars().any(|c| c != ' ') && advance(&candidate) > width {
|
||||
out.push(std::mem::take(&mut line));
|
||||
out.push((std::mem::take(&mut line), soft, wrap_gap));
|
||||
soft = true;
|
||||
// A mid-word piece (i > 0) had no separator of its own; the first piece of a
|
||||
// word had exactly the separators just taken.
|
||||
wrap_gap = if i == 0 { gap_here } else { 0 };
|
||||
line = piece.to_string();
|
||||
} else {
|
||||
line = candidate;
|
||||
@@ -366,7 +381,7 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec
|
||||
}
|
||||
line.push_str(&" ".repeat(gap));
|
||||
if !line.is_empty() || out.len() == start {
|
||||
out.push(line);
|
||||
out.push((line, soft, wrap_gap));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -395,6 +410,9 @@ pub struct BlockStyle {
|
||||
/// Fill colour (RGB 0–1).
|
||||
pub color: Option<[f64; 3]>,
|
||||
pub align: Option<crate::added::Align>,
|
||||
/// Rows (the text's lines between `\n`s) aligned other than the paragraph, by index; `None`
|
||||
/// and rows past the end follow `align` ([`crate::paragraph_align`]).
|
||||
pub aligns: Vec<Option<crate::added::Align>>,
|
||||
/// Draw a line under each line of text.
|
||||
pub underline: Option<bool>,
|
||||
/// Line spacing as a multiple of the font size (1.2 is ordinary).
|
||||
|
||||
@@ -301,6 +301,14 @@ pub(super) struct Shown {
|
||||
pub(super) paint: Paint,
|
||||
pub(super) logical_order: bool,
|
||||
pub(super) actual_text: bool,
|
||||
/// Set when a `/PCSoft` or `/PCHard` marker was seen since the previous text line.
|
||||
pub(super) soft_break: bool,
|
||||
pub(super) hard_break: bool,
|
||||
/// The `/N` a `/PCSoft` marker carried: how many separator characters the wrap consumed (0
|
||||
/// for a Chinese/Japanese word split). Meaningless unless `soft_break` is set; 1 for a
|
||||
/// `/PCSoft` with no `/N` (a file saved before this was tracked, or by another writer),
|
||||
/// matching the single space a rejoin always used before #2529.
|
||||
pub(super) soft_gap: usize,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
@@ -549,6 +557,9 @@ struct TextRunState<'a> {
|
||||
marked: &'a MarkedContent,
|
||||
carry: &'a mut Carry,
|
||||
out: &'a mut Vec<Shown>,
|
||||
soft_break: bool,
|
||||
hard_break: bool,
|
||||
soft_gap: usize,
|
||||
}
|
||||
|
||||
fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> {
|
||||
@@ -745,6 +756,9 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
|
||||
paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() },
|
||||
logical_order: false,
|
||||
actual_text: false,
|
||||
soft_break: state.soft_break,
|
||||
hard_break: state.hard_break,
|
||||
soft_gap: state.soft_gap,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -769,11 +783,39 @@ pub(super) fn interpret(
|
||||
let mut bt = 0usize;
|
||||
let mut marked =
|
||||
MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() };
|
||||
// A rewrite names a wrap (`/PCSoft`) or a break it kept (`/PCHard`) in an empty marker
|
||||
// before the next line. Neither name is a paragraph region, so it does not change scope.
|
||||
let mut soft_break = false;
|
||||
let mut hard_break = false;
|
||||
// `/PCSoft`'s `/N` property: how many separator characters the wrap consumed. A `/PCSoft`
|
||||
// with no properties dict (a file saved before this was tracked, or by another writer) reads
|
||||
// as 1, the single space a rejoin always used before #2529.
|
||||
let mut soft_gap = 1usize;
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
// No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing
|
||||
// sequence (#3573). Both stacks stay bounded by the number of operators.
|
||||
b"BMC" | b"BDC" => {
|
||||
if op.name(0) == Some(b"PCSoft") {
|
||||
soft_break = true;
|
||||
hard_break = false;
|
||||
soft_gap = op
|
||||
.operands
|
||||
.get(1)
|
||||
.and_then(Object::as_dict)
|
||||
.and_then(|d| d.get(b"N"))
|
||||
.and_then(Object::as_int)
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.unwrap_or(1);
|
||||
// The marker is untrusted and later expands into spaces during reflow.
|
||||
// Charge it to the same page-wide budget as glyph text, so many individually
|
||||
// small markers cannot multiply the allocation past the limit either.
|
||||
carry.text_left =
|
||||
carry.text_left.checked_sub(soft_gap).ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
|
||||
} else if op.name(0) == Some(b"PCHard") {
|
||||
hard_break = true;
|
||||
soft_break = false;
|
||||
}
|
||||
marked.begin(doc, op, i, logical_text, out.len());
|
||||
}
|
||||
b"EMC" => {
|
||||
@@ -878,8 +920,28 @@ pub(super) fn interpret(
|
||||
tm = tlm;
|
||||
}
|
||||
b"Tj" | b"TJ" | b"'" | b"\"" => {
|
||||
let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out };
|
||||
let before = out.len();
|
||||
let mut state = TextRunState {
|
||||
ts: &mut ts,
|
||||
tm: &mut tm,
|
||||
tlm: &mut tlm,
|
||||
at_bt: &at_bt,
|
||||
bt,
|
||||
marked: &marked,
|
||||
carry,
|
||||
out: &mut out,
|
||||
soft_break,
|
||||
hard_break,
|
||||
soft_gap,
|
||||
};
|
||||
show_text(op, i, &mut state)?;
|
||||
// A numeric-only TJ adjustment shows no glyphs and pushes nothing: the break
|
||||
// belongs to whichever text-showing operator comes next.
|
||||
if out.len() > before {
|
||||
soft_break = false;
|
||||
hard_break = false;
|
||||
soft_gap = 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,9 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) ->
|
||||
clips: s.state.render_mode >= 4,
|
||||
unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned),
|
||||
runs: vec![(s.text.clone(), s.ink.len(), false)],
|
||||
soft_break: s.soft_break,
|
||||
hard_break: s.hard_break,
|
||||
soft_gap: s.soft_gap,
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
@@ -394,6 +397,9 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
clips: s.state.render_mode >= 4,
|
||||
unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned),
|
||||
runs: vec![(s.text.clone(), s.ink.len(), false)],
|
||||
soft_break: s.soft_break,
|
||||
hard_break: s.hard_break,
|
||||
soft_gap: s.soft_gap,
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
|
||||
@@ -6,8 +6,8 @@ use super::fallback::{
|
||||
use super::interp::{Carry, Origin, TextState, interpret};
|
||||
use super::lines::{content_streams, page_dict, rewritten_contents, splice, text_lines};
|
||||
use super::{
|
||||
Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, group_blocks, is_centred, parse,
|
||||
rewrite_styled_block,
|
||||
Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, alignment_frame, group_blocks,
|
||||
is_centred, parse, rewrite_styled_block,
|
||||
};
|
||||
|
||||
/// What replacing a line did.
|
||||
@@ -501,7 +501,6 @@ fn underline_ops(fill: &[Op], tm: &[f64; 6], size: f64, underlines: &[(f64, f64,
|
||||
|
||||
/// Alignment: each line's offset from the left edge.
|
||||
struct Alignment {
|
||||
align: Option<crate::added::Align>,
|
||||
width: f64,
|
||||
k: f64,
|
||||
anchored_right: bool,
|
||||
@@ -510,10 +509,11 @@ struct Alignment {
|
||||
}
|
||||
|
||||
impl Alignment {
|
||||
/// `visible` is the width the line is aligned on, in user space; the offset is in text space.
|
||||
fn offset(&self, visible: f64) -> f64 {
|
||||
/// `visible` is the width the line is aligned on, in user space, and `align` its row's
|
||||
/// alignment; the offset is in text space.
|
||||
fn offset(&self, visible: f64, align: Option<crate::added::Align>) -> f64 {
|
||||
let free = (self.width - visible).max(0.0) / self.k;
|
||||
match self.align {
|
||||
match align {
|
||||
Some(crate::added::Align::Center) => free / 2.0,
|
||||
// A right-to-left line anchored at its right edge may start left of the old box.
|
||||
Some(crate::added::Align::Right) if self.anchored_right => (self.right_edge - visible) / self.k,
|
||||
@@ -531,7 +531,9 @@ struct LineWriter<'a> {
|
||||
alignment: Alignment,
|
||||
encode: &'a Encoder,
|
||||
source: Option<&'a Metrics>,
|
||||
justify: bool,
|
||||
/// The paragraph's alignment, and the rows aligned on their own ([`BlockStyle::aligns`]).
|
||||
align: Option<crate::added::Align>,
|
||||
aligns: &'a [Option<crate::added::Align>],
|
||||
/// Whether `Tw` reaches every shown space.
|
||||
word_spacing_reaches_spaces: bool,
|
||||
width: f64,
|
||||
@@ -551,13 +553,18 @@ struct LineCursor {
|
||||
}
|
||||
|
||||
impl LineWriter<'_> {
|
||||
/// Row `row`'s alignment: its own, or the paragraph's.
|
||||
fn align_of(&self, row: usize) -> Option<crate::added::Align> {
|
||||
self.aligns.get(row).copied().flatten().or(self.align)
|
||||
}
|
||||
|
||||
/// Justify: each line but the last fills the width. A stretched line shows only its words, as
|
||||
/// before #4199: word spacing would reach the spaces before its first word and after its
|
||||
/// last, so they are left out. Nothing to stretch over, or a paragraph drawn at `0 Tz`, isn't
|
||||
/// justified.
|
||||
fn stretch(&self, body: &str, last: bool) -> f64 {
|
||||
fn stretch(&self, body: &str, last: bool, justify: bool) -> f64 {
|
||||
let interior = body.matches(' ').count();
|
||||
if self.justify && !last && interior > 0 && self.scale.abs() > 1e-6 {
|
||||
if justify && !last && interior > 0 && self.scale.abs() > 1e-6 {
|
||||
Some((self.width - self.advance.of(body)).max(0.0) / self.k / self.scale / interior as f64).filter(|tw| tw.is_finite()).unwrap_or(0.0)
|
||||
} else {
|
||||
0.0
|
||||
@@ -578,14 +585,41 @@ impl LineWriter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes one wrapped line, returning its underline (x0, x1, y) in text space.
|
||||
fn write_line(&self, index: usize, line: &str, last: bool, cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<(f64, f64, f64), EditError> {
|
||||
/// Writes one wrapped line of its logical draft row, returning its underline (x0, x1, y) in text space. `wrap`: whether
|
||||
/// this line continues the previous one because a word did not fit, and, when it does, how
|
||||
/// many separator characters the wrap consumed (0 for a Chinese/Japanese word split).
|
||||
fn write_line(
|
||||
&self,
|
||||
index: usize,
|
||||
line: &str,
|
||||
wrap: (usize, bool, usize),
|
||||
last: bool,
|
||||
cursor: &mut LineCursor,
|
||||
ops: &mut Vec<Op>,
|
||||
) -> Result<(f64, f64, f64), EditError> {
|
||||
let n = pdfcraft_content::num;
|
||||
let (row, soft, gap) = wrap;
|
||||
let line_align = self.align_of(row);
|
||||
let body = line.trim_matches(' ');
|
||||
let interior = body.matches(' ').count();
|
||||
let tw = self.stretch(body, last);
|
||||
let tw = self.stretch(body, last, line_align == Some(crate::added::Align::Justify));
|
||||
let (shown, visible) = self.shown(line, body, tw);
|
||||
let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible) };
|
||||
let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible, line_align) };
|
||||
if index > 0 {
|
||||
// Inside this text object, so the marker starts and ends here. `/PCHard` is a newline
|
||||
// that was already in the text. `/PCSoft` rejoins when the box widens, with `/N` the
|
||||
// exact number of separator characters the wrap consumed (0 for a Chinese/Japanese
|
||||
// word split), so a rejoin restores what was actually there instead of guessing a
|
||||
// single space and silently dropping the rest of a typed run (#2529).
|
||||
if soft {
|
||||
let mut properties = Dict::new();
|
||||
properties.set(b"N".to_vec(), Object::Int(i64::try_from(gap).unwrap_or(i64::MAX)));
|
||||
ops.push(Op::new("BDC", vec![Object::name("PCSoft"), Object::Dict(properties)]));
|
||||
} else {
|
||||
ops.push(Op::new("BMC", vec![Object::name("PCHard")]));
|
||||
}
|
||||
ops.push(Op::new("EMC", vec![]));
|
||||
}
|
||||
if index > 0 || dx != 0.0 {
|
||||
ops.push(Op::new("Td", vec![n(dx - cursor.x), n(if index > 0 { -self.lead } else { 0.0 })]));
|
||||
}
|
||||
@@ -633,8 +667,18 @@ impl LineWriter<'_> {
|
||||
}
|
||||
|
||||
/// Writes every wrapped line, returning their underlines.
|
||||
fn write_all(&self, wrapped: &[String], cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<Vec<(f64, f64, f64)>, EditError> {
|
||||
wrapped.iter().enumerate().map(|(i, line)| self.write_line(i, line, i + 1 == wrapped.len(), cursor, ops)).collect()
|
||||
fn write_all(&self, wrapped: &[(String, bool, usize)], cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<Vec<(f64, f64, f64)>, EditError> {
|
||||
let mut row = 0usize;
|
||||
wrapped
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (line, soft, gap))| {
|
||||
if i > 0 && !soft {
|
||||
row = row.saturating_add(1);
|
||||
}
|
||||
self.write_line(i, line, (row, *soft, *gap), i + 1 == wrapped.len(), cursor, ops)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,7 +697,7 @@ struct BlockPlan<'a> {
|
||||
underline: bool,
|
||||
}
|
||||
|
||||
fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[String], show_font: String) -> Result<Vec<Op>, EditError> {
|
||||
fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, bool, usize)], show_font: String) -> Result<Vec<Op>, EditError> {
|
||||
let n = pdfcraft_content::num;
|
||||
let mut ops = vec![Op::new("q", vec![])];
|
||||
ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?);
|
||||
@@ -747,6 +791,23 @@ pub(crate) fn rewrite_block_from(
|
||||
) -> Result<LineEdit, EditError> {
|
||||
let blocks = group_blocks(&lines);
|
||||
let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?;
|
||||
// Existing PDF paragraphs do not yet persist selected-row alignment intent. Refuse
|
||||
// that operation before writing anything; whole-paragraph `align` remains supported.
|
||||
if style.aligns.iter().any(Option::is_some) {
|
||||
return Err(EditError::Invalid(
|
||||
"selected-row alignment of existing paragraphs is not supported until it can be preserved across editing sessions".into(),
|
||||
));
|
||||
}
|
||||
// An alignment for a one-row paragraph is laid out across the column it sits in, as a
|
||||
// paragraph of that width moved to its left edge.
|
||||
let framed;
|
||||
let style = match alignment_frame(&lines, &blocks, &b, style, text.unwrap_or(&b.editor_text)) {
|
||||
Some([left, right]) => {
|
||||
framed = BlockStyle { width: Some(right - left), offset: Some([left - b.rect[0], 0.0]), ..style.clone() };
|
||||
&framed
|
||||
}
|
||||
None => style,
|
||||
};
|
||||
let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect();
|
||||
// One clipping run is enough: rewriting the paragraph would drop its mask with the rest.
|
||||
let mut dropped = coincident_ops(&lines, &members);
|
||||
@@ -758,6 +819,9 @@ pub(crate) fn rewrite_block_from(
|
||||
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
|
||||
let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?;
|
||||
if dispatch.use_styled_path && style.align == Some(crate::added::Align::Justify) {
|
||||
return Err(EditError::Invalid("justification of mixed-style existing paragraphs is not supported".into()));
|
||||
}
|
||||
if dispatch.use_styled_path {
|
||||
return rewrite_styled_block(doc, page, &b, &lines, text, style, max_glyphs);
|
||||
}
|
||||
@@ -852,10 +916,11 @@ pub(crate) fn rewrite_block_from(
|
||||
};
|
||||
let writer = LineWriter {
|
||||
advance: &advance,
|
||||
alignment: Alignment { align, width, k, anchored_right, right_edge, rtl },
|
||||
alignment: Alignment { width, k, anchored_right, right_edge, rtl },
|
||||
encode: &font.encode,
|
||||
source: source_metrics,
|
||||
justify: align == Some(crate::added::Align::Justify),
|
||||
align,
|
||||
aligns: &style.aligns,
|
||||
word_spacing_reaches_spaces,
|
||||
width,
|
||||
scale: o_state.scale,
|
||||
|
||||
@@ -12,5 +12,5 @@ mod grouping;
|
||||
#[path = "text_regions/styled.rs"]
|
||||
mod styled;
|
||||
|
||||
pub(super) use grouping::{group_blocks, group_reading_blocks, is_centred};
|
||||
pub(super) use grouping::{alignment_frame, group_blocks, group_reading_blocks, is_centred};
|
||||
pub(super) use styled::rewrite_styled_block;
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union};
|
||||
use super::{BlockStyle, HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union};
|
||||
|
||||
fn same_plane(a: &TextLine, b: &TextLine) -> bool {
|
||||
let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm));
|
||||
@@ -42,6 +42,54 @@ fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option<f64> {
|
||||
Some(best as f64 / 4.0)
|
||||
}
|
||||
|
||||
/// Where an alignment picked for a one-row paragraph is measured, as `[left, right]`: the column of
|
||||
/// text it sits in (an overlapping paragraph's left edge, to the furthest right its rows reach)
|
||||
/// when that column holds it, otherwise its own box. Of the columns that hold it, the narrowest is
|
||||
/// its own, the nearest on a tie: a heading across the whole page, even one closer than the body,
|
||||
/// is wider than the column the title sits in, and gives it no frame while a narrower one does. A lone row's own box is no frame
|
||||
/// to align in: left-aligning a title centred on the page left it where it was, and since a single
|
||||
/// row grows to the page's margin, centring it again moved it right each time. `None` when no
|
||||
/// alignment is asked for, when the paragraph is several rows, turned, or given a width or a move of
|
||||
/// its own, or when its `draft` text changes and no column holds it.
|
||||
pub(crate) fn alignment_frame(lines: &[TextLine], blocks: &[TextBlock], block: &TextBlock, style: &BlockStyle, draft: &str) -> Option<[f64; 2]> {
|
||||
// Every row of the draft is aligned, by its own alignment or the paragraph's: moving the
|
||||
// paragraph to its column's edge would move a row that has none. Entries past the draft's
|
||||
// rows name no row.
|
||||
let asked = (0..crate::paragraph_align::count(draft)).all(|row| style.aligns.get(row).copied().flatten().or(style.align).is_some());
|
||||
if !asked || style.width.is_some() || style.offset.is_some() || block.rows.len() != 1 || block.rotation.abs() > 0.01 {
|
||||
return None;
|
||||
}
|
||||
let own = [block.rect[0], block.rect[2]];
|
||||
let tolerance = block.size.max(1.0) * 0.5;
|
||||
let gap = |other: &TextBlock| (other.rect[1] - block.rect[3]).max(block.rect[1] - other.rect[3]).max(0.0);
|
||||
// The column an overlapping paragraph makes, when it holds the row.
|
||||
let column = |other: &TextBlock| -> Option<[f64; 2]> {
|
||||
let left = other.rect[0];
|
||||
// Whole rows, not their runs: a row of several styles reaches its last run's right edge.
|
||||
let right = other
|
||||
.rows
|
||||
.iter()
|
||||
.filter_map(|row| row_rect(row, lines))
|
||||
.filter(|row| (row[0] - left).abs() < tolerance)
|
||||
.map(|row| row[2])
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
(right.is_finite() && right - left > own[1] - own[0] && own[0] >= left - tolerance && own[1] <= right + tolerance).then_some([left, right])
|
||||
};
|
||||
let frame = blocks
|
||||
.iter()
|
||||
.filter(|other| other.lines.iter().all(|i| !block.lines.contains(i)) && other.rect[2] > own[0] && other.rect[0] < own[1])
|
||||
.filter_map(|other| column(other).map(|frame| (frame, gap(other))))
|
||||
.min_by(|(a, near_a), (b, near_b)| (a[1] - a[0]).total_cmp(&(b[1] - b[0])).then(near_a.total_cmp(near_b)))
|
||||
.map(|(frame, _)| frame);
|
||||
// The row's own box, with no column to align in, is no width for new text to wrap at: edited,
|
||||
// the row keeps the width it would have had without the alignment.
|
||||
match frame {
|
||||
Some(frame) => Some(frame),
|
||||
None if draft != block.editor_text => None,
|
||||
None => Some(own),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a block reads as centred, and so must keep its centre when it grows or wraps.
|
||||
///
|
||||
/// Several ragged rows whose left edges disagree while their centres agree are centred. A one row
|
||||
@@ -227,14 +275,15 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
}
|
||||
}
|
||||
let mut blocks: Vec<TextBlock> = Vec::new();
|
||||
for row in rows {
|
||||
for row_at in 0..rows.len() {
|
||||
let Some(row) = rows.get(row_at) else { continue };
|
||||
let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue };
|
||||
let (Some(rect), Some(frame)) = (row_rect(&row, lines), frame_row_rect(&row, lines)) else { continue };
|
||||
let (Some(rect), Some(frame)) = (row_rect(row, lines), frame_row_rect(row, lines)) else { continue };
|
||||
let joins = blocks.last().is_some_and(|block| {
|
||||
// A bullet of its own opens a list item (#2564): such a row is a new paragraph, and
|
||||
// letting it join is what made an edit of the row above delete this item and the ones
|
||||
// after it.
|
||||
if starts_list_item(&row, lines) {
|
||||
if starts_list_item(row, lines) {
|
||||
return false;
|
||||
}
|
||||
let Some(prev_row) = block.rows.last() else { return false };
|
||||
@@ -253,6 +302,36 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
|| ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol
|
||||
|| (pr[2] - frame[2]).abs() < tol
|
||||
|| hanging;
|
||||
// Once a paragraph has two rows, its own leading is the measured baseline gap. A
|
||||
// blank line is about twice that, so a wider gap is the next paragraph (#2517). A
|
||||
// single row uses the text object's TL when it has one: a gap equal to that TL
|
||||
// joins, and a gap of twice the TL does not. `Td` does not set TL (`TD` does). A
|
||||
// step that repeats on the following row is that leading; one large gap, with no
|
||||
// second step to compare, stays a break. The old cap of 2.5 × the size joined the
|
||||
// blank line too.
|
||||
let own_leading = block.rows.len().checked_sub(2).and_then(|i| {
|
||||
let above = block.rows.get(i)?.first().and_then(|n| lines.get(*n));
|
||||
let below = prev_row.first().and_then(|n| lines.get(*n));
|
||||
match (above, below) {
|
||||
(Some(above), Some(below)) => Some((above.frame_origin().1 - below.frame_origin().1).abs()),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
let stated = (prev.origin.state.leading > 0.0).then(|| prev.wrap_leading()).filter(|lead| lead.is_finite() && *lead > tol * 0.4);
|
||||
// The next row is not in this block yet. Its gap is only a leading when it matches
|
||||
// this one: a blank line is a single wider gap and has no matching step after it.
|
||||
let repeated = rows.get(row_at.saturating_add(1)).and_then(|next| {
|
||||
let nfirst = next.first().and_then(|i| lines.get(*i))?;
|
||||
let nframe = frame_row_rect(next, lines)?;
|
||||
let ngap = first.frame_origin().1 - nfirst.frame_origin().1;
|
||||
let same = same_plane(first, nfirst)
|
||||
&& (mixed || same_style(first, nfirst))
|
||||
&& first.size.min(nfirst.size) >= first.size.max(nfirst.size) * 0.4
|
||||
&& (nframe[0] - frame[0]).abs() < tol;
|
||||
let scale = gap.abs().max(ngap.abs());
|
||||
if same && ngap > tol * 0.8 && scale < tol * 4.0 && (ngap - gap).abs() <= scale * 0.08 + 0.5 { Some(gap) } else { None }
|
||||
});
|
||||
let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).or(stated).or(repeated).map_or(tol * 2.05, |lead| lead * 1.5);
|
||||
// OCR lines stay apart: the scan fixes where each one is, so they never rewrap together.
|
||||
(!mixed || !(invisible(prev) || invisible(first)))
|
||||
&& (mixed || same_style(prev, first))
|
||||
@@ -262,12 +341,13 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
&& aligned
|
||||
&& first.size.min(prev.size) >= first.size.max(prev.size) * 0.4
|
||||
&& gap > first.size.min(prev.size) * 0.8
|
||||
&& gap < first.size.max(prev.size) * 2.5
|
||||
&& gap < max_gap
|
||||
});
|
||||
if !joins {
|
||||
blocks.push(TextBlock {
|
||||
text: String::new(),
|
||||
editor_text: String::new(),
|
||||
reflow_text: String::new(),
|
||||
spans: Vec::new(),
|
||||
rect,
|
||||
base_font: first.base_font.clone(),
|
||||
@@ -286,7 +366,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
if !block.rows.is_empty() {
|
||||
block.editor_text.push('\n');
|
||||
}
|
||||
for &index in &row {
|
||||
for &index in row {
|
||||
let Some(line) = lines.get(index) else { continue };
|
||||
// Some producers position separate words without encoding a space.
|
||||
if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i))
|
||||
@@ -313,7 +393,62 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
}
|
||||
block.text.push_str(row_text.trim_matches(paragraph_separator));
|
||||
block.rect = union(block.rect, rect);
|
||||
block.rows.push(row);
|
||||
block.rows.push(row.clone());
|
||||
}
|
||||
for block in &mut blocks {
|
||||
block.reflow_text = reflow_text(block, lines);
|
||||
}
|
||||
blocks
|
||||
}
|
||||
|
||||
/// The text a width change should reflow. A `/PCSoft` line is a wrap and rejoins. A `/PCHard`
|
||||
/// line, or a short line that is not marked, keeps its newline. An unmarked line that reaches
|
||||
/// the paragraph's right edge and is wide enough to be a measure rejoins too: that is how a
|
||||
/// file written elsewhere wraps a body line.
|
||||
fn reflow_text(block: &TextBlock, lines: &[TextLine]) -> String {
|
||||
let parts: Vec<&str> = block.editor_text.split('\n').collect();
|
||||
if parts.len() != block.rows.len() {
|
||||
return block.editor_text.clone();
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
if index > 0 {
|
||||
match break_gap(block, lines, index) {
|
||||
Some(_) if out.ends_with('-') => {
|
||||
out.pop();
|
||||
}
|
||||
// `gap` is the exact number of separator characters the wrap consumed (0 for a
|
||||
// Chinese/Japanese word split, which has no space of its own) when the marker or
|
||||
// the source recorded it; a line already ending in whitespace is left alone so a
|
||||
// row whose own text kept a trailing space isn't doubled.
|
||||
Some(gap) if !out.ends_with(char::is_whitespace) => out.push_str(&" ".repeat(gap)),
|
||||
Some(_) => {}
|
||||
None => out.push('\n'),
|
||||
}
|
||||
}
|
||||
out.push_str(part);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `None`: a hard break, kept as a newline. `Some(gap)`: a soft wrap, rejoined with exactly `gap`
|
||||
/// spaces — the count a `/PCSoft` marker recorded ([`TextLine::soft_gap`]), or, for a line this
|
||||
/// app did not mark (a file written elsewhere, or an older save), a single space when the
|
||||
/// geometry looks like an ordinary wrapped body line (`#2529`).
|
||||
fn break_gap(block: &TextBlock, lines: &[TextLine], row_index: usize) -> Option<usize> {
|
||||
let line = block.rows.get(row_index).and_then(|row| row.first()).and_then(|i| lines.get(*i))?;
|
||||
if line.hard_break {
|
||||
return None;
|
||||
}
|
||||
if line.soft_break {
|
||||
return Some(line.soft_gap);
|
||||
}
|
||||
let prev = block.rows.get(row_index.wrapping_sub(1)).and_then(|row| row_rect(row, lines))?;
|
||||
let next = block.rows.get(row_index).and_then(|row| row_rect(row, lines))?;
|
||||
let size = line.size.max(1.0);
|
||||
let slack = block.rect[2] - prev[2];
|
||||
let width = prev[2] - prev[0];
|
||||
// Short rows define the right edge by their own letters. A body line that fills the
|
||||
// measure leaves almost no room for the next word.
|
||||
(slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack).then_some(1)
|
||||
}
|
||||
@@ -401,13 +401,26 @@ fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextB
|
||||
Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0)))
|
||||
}
|
||||
|
||||
type WrappedStyledRows = (StyledRows, RowRanges, Vec<usize>, Vec<bool>, Vec<usize>);
|
||||
|
||||
fn wrap_styled_rows(
|
||||
chars: &[StyledChar],
|
||||
width: f64,
|
||||
measure: impl Fn(&[StyledChar]) -> Result<f64, EditError>,
|
||||
) -> Result<(StyledRows, RowRanges), EditError> {
|
||||
) -> Result<WrappedStyledRows, EditError> {
|
||||
let mut rows: Vec<Vec<StyledChar>> = Vec::new();
|
||||
let mut row_ranges = Vec::new();
|
||||
// True when this row starts because the previous word did not fit, not because of a newline.
|
||||
let mut row_soft = Vec::new();
|
||||
let mut next_soft = false;
|
||||
// How many separator characters a wrap trimmed off the row that just ended, for the row
|
||||
// about to start (0 for a Chinese/Japanese word split, which trims none; meaningless unless
|
||||
// the row it belongs to is soft). Mirrors `next_soft`.
|
||||
let mut row_gap = Vec::new();
|
||||
let mut next_gap = 0usize;
|
||||
// Logical draft rows drive per-paragraph alignment independently of soft wraps.
|
||||
let mut row_lines: Vec<usize> = Vec::new();
|
||||
let mut draft_line = 0usize;
|
||||
let mut row_start = 0;
|
||||
let mut row = Vec::new();
|
||||
let mut row_width = 0.0;
|
||||
@@ -419,7 +432,13 @@ fn wrap_styled_rows(
|
||||
continue;
|
||||
}
|
||||
if item.0 == '\n' {
|
||||
row_soft.push(next_soft);
|
||||
next_soft = false;
|
||||
row_gap.push(next_gap);
|
||||
next_gap = 0;
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
row_lines.push(draft_line);
|
||||
draft_line = draft_line.saturating_add(1);
|
||||
rows.push(std::mem::take(&mut row));
|
||||
row_width = 0.0;
|
||||
at += 1;
|
||||
@@ -439,10 +458,17 @@ fn wrap_styled_rows(
|
||||
let word = chars.get(at..end).unwrap_or_default();
|
||||
let word_width = measure(word)?;
|
||||
if !row.is_empty() && !item.0.is_whitespace() && row_width + word_width > width + 0.5 {
|
||||
let mut trimmed = 0usize;
|
||||
while row.last().is_some_and(|c: &StyledChar| c.0.is_whitespace()) {
|
||||
row.pop();
|
||||
trimmed += 1;
|
||||
}
|
||||
row_soft.push(next_soft);
|
||||
next_soft = true;
|
||||
row_gap.push(next_gap);
|
||||
next_gap = trimmed;
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
row_lines.push(draft_line);
|
||||
rows.push(std::mem::take(&mut row));
|
||||
row_width = 0.0;
|
||||
row_start = at;
|
||||
@@ -452,10 +478,13 @@ fn wrap_styled_rows(
|
||||
at = end;
|
||||
}
|
||||
if !row.is_empty() {
|
||||
row_soft.push(next_soft);
|
||||
row_gap.push(next_gap);
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
row_lines.push(draft_line);
|
||||
rows.push(row);
|
||||
}
|
||||
Ok((rows, row_ranges))
|
||||
Ok((rows, row_ranges, row_lines, row_soft, row_gap))
|
||||
}
|
||||
|
||||
fn apply_range_styles<'a>(
|
||||
@@ -609,6 +638,92 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range<usize>
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Operator index of the paragraph's first text object, and of the next line's text object
|
||||
/// when one follows.
|
||||
fn paragraph_span(block: &TextBlock, lines: &[TextLine]) -> Option<(usize, Option<usize>)> {
|
||||
let belongs: std::collections::HashSet<usize> = block.lines.iter().copied().collect();
|
||||
let start = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.origin.bt_op).min()?;
|
||||
let end =
|
||||
block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))).max()?;
|
||||
let next = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| !belongs.contains(index))
|
||||
.filter_map(|(_, line)| (line.origin.bt_op > end).then_some(line.origin.bt_op))
|
||||
.min();
|
||||
Some((start, next))
|
||||
}
|
||||
|
||||
/// The scope shared by every line of the paragraph, when each one is in the same marked region.
|
||||
fn shared_scope(block: &TextBlock, lines: &[TextLine]) -> Option<usize> {
|
||||
let mut scopes = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.scope);
|
||||
let first = scopes.next().flatten()?;
|
||||
scopes.all(|scope| scope == Some(first)).then_some(first)
|
||||
}
|
||||
|
||||
/// Where an extra `/P` marker belongs.
|
||||
///
|
||||
/// The marker is omitted when the paragraph is already one marked region and the next line is
|
||||
/// in a different one. Otherwise it closes after the paragraph's last `ET` (`ops.len()` means
|
||||
/// append it after the stream). Closing at the next line's `BT` is too late when that line's
|
||||
/// region opened with `BDC` before the `BT`: the `EMC` would end the neighbour's region. Closing
|
||||
/// inside the rewritten operators leaves a kept source row outside the marker.
|
||||
///
|
||||
/// A neighbour in the same text object has no `BT` of its own. Closing after the shared `ET`
|
||||
/// would take that neighbour into the marker. The marker then opens after `BT` and closes
|
||||
/// before the neighbour's text, so `BT` and `ET` stay matched.
|
||||
fn paragraph_marker(block: &TextBlock, lines: &[TextLine], ops: &[Op]) -> Option<(usize, Option<usize>, usize)> {
|
||||
let (start, next) = paragraph_span(block, lines)?;
|
||||
if let Some(region) = shared_scope(block, lines)
|
||||
&& let Some(at) = next
|
||||
{
|
||||
let next_scope = lines.iter().find(|line| line.origin.bt_op == at).map(|line| line.scope);
|
||||
if next_scope != Some(Some(region)) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let belongs: std::collections::HashSet<usize> = block.lines.iter().copied().collect();
|
||||
let end =
|
||||
block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))).max()?;
|
||||
let after_text = ops
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(end.saturating_add(1))
|
||||
.find(|(_, op)| op.op.as_slice() == b"ET")
|
||||
.map(|(index, _)| index.saturating_add(1))
|
||||
.unwrap_or(ops.len());
|
||||
let neighbour = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| !belongs.contains(index))
|
||||
.filter_map(|(_, line)| line.ops.iter().copied().filter(|op| *op > end).min())
|
||||
.min();
|
||||
let open = block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied()).min()?;
|
||||
if let Some(op) = neighbour.filter(|op| *op < after_text) {
|
||||
// `BT` stays outside the marker. Both ends are inside the one text object.
|
||||
if open > start {
|
||||
return Some((open, None, op));
|
||||
}
|
||||
}
|
||||
// The last paragraph of a shared text object has no neighbour after it. Opening at the
|
||||
// shared `BT` would take the preceding paragraph into the marker. Open at this paragraph's
|
||||
// first operator instead, and close before the shared `ET`, so both ends stay inside it.
|
||||
let preceding =
|
||||
lines.iter().enumerate().filter(|(index, _)| !belongs.contains(index)).any(|(_, line)| line.ops.iter().any(|op| *op > start && *op < open));
|
||||
if preceding {
|
||||
if let Some(et) = after_text.checked_sub(1).filter(|index| ops.get(*index).is_some_and(|op| op.op.as_slice() == b"ET"))
|
||||
&& open > start
|
||||
&& open < et
|
||||
{
|
||||
return Some((open, None, et));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
// `None` is the next-line index: the close is after this paragraph's `ET`, not at the
|
||||
// following line's `BT`.
|
||||
Some((start, None, after_text))
|
||||
}
|
||||
|
||||
struct RewriteAnchor<'a> {
|
||||
lead: &'a TextLine,
|
||||
drop: std::collections::HashSet<usize>,
|
||||
@@ -651,6 +766,78 @@ fn rewrite_anchor<'a>(
|
||||
RewriteAnchor { lead, drop, start, split }
|
||||
}
|
||||
|
||||
/// Open (`op` is `"BMC"`) or close (`"EMC"`) the `/P` marker that wraps the whole rewrite when
|
||||
/// `marked` (every row is in this rewrite, see [`rewrite_styled_block`]), so the paragraph
|
||||
/// cannot be mistaken for a neighbour by geometry alone (#2517).
|
||||
fn push_whole_paragraph_marker(block_ops: &mut Vec<Op>, marked: bool, op: &str) {
|
||||
if !marked {
|
||||
return;
|
||||
}
|
||||
let operands = if op == "BMC" { vec![Object::name("P")] } else { Vec::new() };
|
||||
block_ops.push(Op::new(op, operands));
|
||||
}
|
||||
|
||||
/// An empty marker between two rows of one rewrite. `/PCHard` is a newline that was already in
|
||||
/// the text. `/PCSoft` is a wrap and rejoins when the box widens, with `/N` the exact number of
|
||||
/// separator characters it consumed (0 for a Chinese/Japanese word split), so a rejoin restores
|
||||
/// what was actually there instead of guessing a single space (#2529). Neither is a paragraph
|
||||
/// region, so the lines stay in one scope.
|
||||
fn push_row_break_marker(block_ops: &mut Vec<Op>, row_soft: &[bool], row_gap: &[usize], row_index: usize) {
|
||||
if row_soft.get(row_index).copied().unwrap_or(false) {
|
||||
let mut properties = Dict::new();
|
||||
let gap = row_gap.get(row_index).copied().unwrap_or(1);
|
||||
properties.set(b"N".to_vec(), Object::Int(i64::try_from(gap).unwrap_or(i64::MAX)));
|
||||
block_ops.push(Op::new("BDC", vec![Object::name("PCSoft"), Object::Dict(properties)]));
|
||||
} else {
|
||||
block_ops.push(Op::new("BMC", vec![Object::name("PCHard")]));
|
||||
}
|
||||
block_ops.push(Op::new("EMC", vec![]));
|
||||
}
|
||||
|
||||
/// [`splice`] with the rewritten rows spliced in at `at`, and (when `marker` names one) a `/P`
|
||||
/// marker opened at its start and closed at its `next` line or, when nothing follows, after the
|
||||
/// paragraph's own last `ET` (appended past the stream's last operator when that close is at or
|
||||
/// beyond `ops.len()`, since `splice`'s callback never visits that index).
|
||||
fn splice_with_marker(
|
||||
pieces: &Pieces,
|
||||
ops: &[Op],
|
||||
marker: Option<(usize, Option<usize>, usize)>,
|
||||
at: usize,
|
||||
mut block_ops: Vec<Op>,
|
||||
drop: &std::collections::HashSet<usize>,
|
||||
) -> Vec<Vec<u8>> {
|
||||
let data = splice(pieces, ops, |i| {
|
||||
let mut insert = Vec::new();
|
||||
if let Some((start_at, next, close)) = marker {
|
||||
if i == start_at {
|
||||
insert.push(Op::new("BMC", vec![Object::name("P")]));
|
||||
}
|
||||
if next.is_none() && i == close {
|
||||
insert.push(Op::new("EMC", vec![]));
|
||||
}
|
||||
if next == Some(i) {
|
||||
insert.push(Op::new("EMC", vec![]));
|
||||
}
|
||||
}
|
||||
if i == at {
|
||||
insert.append(&mut block_ops);
|
||||
}
|
||||
(insert, !drop.contains(&i))
|
||||
});
|
||||
if !marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) {
|
||||
return data;
|
||||
}
|
||||
let mut data = data;
|
||||
if let Some(last) = data.last_mut() {
|
||||
if last.last().is_some_and(|byte| !byte.is_ascii_whitespace()) {
|
||||
last.push(b'\n');
|
||||
}
|
||||
// `splice` never visits `ops.len()`, so a close after the final operator is appended.
|
||||
last.extend_from_slice(&pdfcraft_content::serialize_ops(&[Op::new("EMC", vec![])]));
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// Rewrite visual rows while keeping the source style on each surviving character. Resource
|
||||
/// names may differ between Unicode subsets of the same face; encoding searches those subsets.
|
||||
pub(crate) fn rewrite_styled_block(
|
||||
@@ -762,15 +949,21 @@ pub(crate) fn rewrite_styled_block(
|
||||
}
|
||||
Ok(width)
|
||||
};
|
||||
let (rows, row_ranges) = wrap_styled_rows(&chars, width, measure)?;
|
||||
let (rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?;
|
||||
let streams = content_streams(doc, &p.dict)?;
|
||||
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
// A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and
|
||||
// glyph widths stay exactly as they were instead of being rebuilt from the text state.
|
||||
let plain = BlockStyle { ranges: Vec::new(), ..style.clone() } == BlockStyle::default();
|
||||
// glyph widths stay exactly as they were instead of being rebuilt from the text state. A row
|
||||
// aligned on its own is reached; the rows around it are not.
|
||||
let plain = BlockStyle { ranges: Vec::new(), aligns: Vec::new(), ..style.clone() } == BlockStyle::default();
|
||||
let mut kept = if plain {
|
||||
untouched_rows(block, draft, &row_ranges).into_iter().zip(&rows).map(|(same, row)| same && row.iter().all(|c| c.1 < source_count)).collect()
|
||||
untouched_rows(block, draft, &row_ranges)
|
||||
.into_iter()
|
||||
.zip(&rows)
|
||||
.zip(&row_lines)
|
||||
.map(|((same, row), line)| same && row.iter().all(|c| c.1 < source_count) && style.aligns.get(*line).copied().flatten().is_none())
|
||||
.collect()
|
||||
} else {
|
||||
vec![false; rows.len()]
|
||||
};
|
||||
@@ -796,6 +989,12 @@ pub(crate) fn rewrite_styled_block(
|
||||
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?;
|
||||
let (tx, ty) = back.apply(dx, dy);
|
||||
block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)]));
|
||||
// A new row can land one leading above the next paragraph and look like the same
|
||||
// paragraph. When every row is in this rewrite, a paragraph marker keeps them in one
|
||||
// scope so the neighbour stays out (#2517). A row left in the source stream cannot share
|
||||
// that marker, so it is omitted and geometry groups those rows as before.
|
||||
let marked = kept.iter().all(|row| !*row);
|
||||
push_whole_paragraph_marker(&mut block_ops, marked, "BMC");
|
||||
let crop = p.crop(doc);
|
||||
let centered = is_centred(block, lines, (crop[0] + crop[2]) / 2.0, first.size);
|
||||
let source_leading = block
|
||||
@@ -823,11 +1022,16 @@ pub(crate) fn rewrite_styled_block(
|
||||
baseline -= source_leading;
|
||||
continue;
|
||||
}
|
||||
if !row.is_empty() && row_index > 0 {
|
||||
push_row_break_marker(&mut block_ops, &row_soft, &row_gap, row_index);
|
||||
}
|
||||
let w = measured.get(row_index).copied().unwrap_or(0.0);
|
||||
let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left });
|
||||
// The row's own alignment, or the paragraph's.
|
||||
let asked = row_lines.get(row_index).and_then(|l| style.aligns.get(*l)).copied().flatten().or(style.align);
|
||||
let align = asked.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left });
|
||||
let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect);
|
||||
let left = if style.width.is_none() && style.align.is_none() { original_rect[0] } else { block.rect[0] };
|
||||
let available = if style.width.is_none() && style.align.is_none() { original_rect[2] - original_rect[0] } else { width };
|
||||
let left = if style.width.is_none() && asked.is_none() { original_rect[0] } else { block.rect[0] };
|
||||
let available = if style.width.is_none() && asked.is_none() { original_rect[2] - original_rect[0] } else { width };
|
||||
let mut x = left
|
||||
+ match align {
|
||||
crate::added::Align::Center => (available - w) / 2.0,
|
||||
@@ -966,6 +1170,7 @@ pub(crate) fn rewrite_styled_block(
|
||||
style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size)
|
||||
};
|
||||
}
|
||||
push_whole_paragraph_marker(&mut block_ops, marked, "EMC");
|
||||
block_ops.push(Op::new("Q", vec![]));
|
||||
block_ops.extend(after.ops());
|
||||
if split {
|
||||
@@ -974,7 +1179,11 @@ pub(crate) fn rewrite_styled_block(
|
||||
block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect()));
|
||||
}
|
||||
let at = if split { start } else { o.bt_op };
|
||||
let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i)));
|
||||
// A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is
|
||||
// safe. The close is not part of `block_ops`: that vector can end inside a text object
|
||||
// when the rewrite splits one, and a kept row's operators come later in the stream.
|
||||
let marker = if marked { None } else { paragraph_marker(block, lines, &ops) };
|
||||
let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop);
|
||||
let contents = rewritten_contents(doc, &streams, data);
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
doc.update_dict(p.obj, |d| {
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<TextNotice>,
|
||||
/// 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<EditNotice>,
|
||||
}
|
||||
|
||||
/// 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<usize> },
|
||||
}
|
||||
|
||||
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::<Vec<_>>().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<pdfcraft_edit::Backdrop> {
|
||||
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<Option<pdfcraft_edit::Backdrop>> {
|
||||
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<pdfcraft_edit::PageImage> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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(""),
|
||||
|
||||
@@ -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};
|
||||
@@ -82,6 +82,7 @@ pub use pdfcraft_edit::added::{
|
||||
CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect,
|
||||
text_width as added_text_width,
|
||||
};
|
||||
pub use pdfcraft_edit::paragraph_align;
|
||||
pub use pdfcraft_edit::{
|
||||
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
|
||||
};
|
||||
@@ -93,7 +94,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 +126,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};
|
||||
|
||||
@@ -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<EditNotice> {
|
||||
// What it drew with `?` (a value or comment text its font can't draw, stored as typed).
|
||||
let mut notices: Vec<EditNotice> = 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
|
||||
}
|
||||
@@ -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<Vec<u8>>, usize), EditError> {
|
||||
let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?;
|
||||
Ok((bytes, report.merged))
|
||||
|
||||
@@ -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() }))
|
||||
})?
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
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<RenderedPage, String> {
|
||||
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<Option<CommentLayer>, 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() }))
|
||||
})?
|
||||
}
|
||||
}
|
||||
+318
-73
@@ -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<Vec<u8>>, key: &[u8]| -> Vec<u8> {
|
||||
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<Vec<u8>> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<String> {
|
||||
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);
|
||||
@@ -3749,6 +3936,7 @@ fn pasted_added_content_keeps_its_style_and_picture() {
|
||||
size: 12.0,
|
||||
color: [0.8, 0.1, 0.2],
|
||||
align: TextAlign::Center,
|
||||
aligns: Vec::new(),
|
||||
char_spacing: 0.0,
|
||||
};
|
||||
s.apply(id, Edit::AddText { page: 0, text: text.clone() }).unwrap();
|
||||
@@ -7668,6 +7856,7 @@ fn content_edit_keeps_pages_annotations_fields_and_links() {
|
||||
size: 12.0,
|
||||
color: [0.0, 0.0, 0.0],
|
||||
align: TextAlign::Left,
|
||||
aligns: Vec::new(),
|
||||
char_spacing: 0.0,
|
||||
};
|
||||
session.apply(id, Edit::AddText { page: 1, text }).unwrap();
|
||||
@@ -7708,6 +7897,7 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() {
|
||||
size: 12.0,
|
||||
color: [0.0, 0.0, 0.0],
|
||||
align: TextAlign::Left,
|
||||
aligns: Vec::new(),
|
||||
char_spacing: 0.0,
|
||||
},
|
||||
},
|
||||
@@ -8347,11 +8537,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 +9214,124 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec<u8> {
|
||||
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<Vec<u8>>| {
|
||||
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<Vec<u8>>| {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() {
|
||||
let white = "1 g";
|
||||
let rectangle = "0 0 100 100 re f";
|
||||
let artwork = "q 0 g 0.25 0 0 0.25 20 20 cm";
|
||||
let raw = 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 100 100] /Contents [4 0 R 5 0 R 6 0 R 5 0 R 7 0 R] >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{white}\nendstream endobj\n\
|
||||
5 0 obj << /Length {} >> stream\n{rectangle}\nendstream endobj\n\
|
||||
6 0 obj << /Length {} >> stream\n{artwork}\nendstream endobj\n\
|
||||
7 0 obj << /Length 1 >> stream\nQ\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
|
||||
white.len(),
|
||||
rectangle.len(),
|
||||
artwork.len()
|
||||
);
|
||||
// Start with a conventional xref, so the first save can exercise incremental output.
|
||||
let source = pdfcraft_cos::Document::open(Arc::new(raw.into_bytes())).unwrap();
|
||||
let bytes = pdfcraft_cos::write_full(&source, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
let mut s = Session::new();
|
||||
let id = s.open("shared-backdrop.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let black = |s: &Session, id| {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
|
||||
let out = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
assert_eq!((out.width, out.height), (100, 100));
|
||||
// PDF point (30, 30), inside the later black square, in top-down raster coordinates.
|
||||
let i = ((70 * out.width + 30) * 4) as usize;
|
||||
let rgba = &out.rgba[i..i + 4];
|
||||
assert!(rgba[0] < 20 && rgba[1] < 20 && rgba[2] < 20, "later artwork survives: {rgba:?}");
|
||||
};
|
||||
black(&s, id);
|
||||
s.apply(
|
||||
id,
|
||||
Edit::AddBackground { pages: vec![0], settings: Background { color: [1.0, 0.0, 0.0], ..Background::default() }, replace: false, file: None },
|
||||
)
|
||||
.unwrap();
|
||||
black(&s, id);
|
||||
s.undo(id).unwrap();
|
||||
black(&s, id);
|
||||
s.redo(id).unwrap();
|
||||
black(&s, id);
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let reopened = s.open("incremental.pdf", None, saved.clone(), None).unwrap();
|
||||
black(&s, reopened);
|
||||
let doc = pdfcraft_cos::Document::open(saved).unwrap();
|
||||
let full = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
let rewritten = s.open("full.pdf", None, Arc::new(full), None).unwrap();
|
||||
black(&s, rewritten);
|
||||
s.apply(rewritten, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap();
|
||||
black(&s, rewritten);
|
||||
}
|
||||
@@ -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) => ("<strong><em>", "</em></strong>"),
|
||||
(false, true, false) => ("<strong>", "</strong>"),
|
||||
(false, false, _) => ("<em>", "</em>"),
|
||||
};
|
||||
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<String> = 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<char>, outside: Option<char>) -> 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<String> = 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<String> = 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
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<usize> = ["# Annual Report\n\n", "## Overview\n\n", "Some \\<body\\> 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 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<strong>.</strong>bar");
|
||||
assert_eq!(paragraph(&[("x", true, false), ("y", false, true)]), "<strong>x</strong>*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}");
|
||||
}
|
||||
@@ -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,9 +11,14 @@ 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
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-ccitt.workspace = true
|
||||
hayro-jbig2.workspace = true
|
||||
proptest.workspace = true
|
||||
weezl.workspace = true
|
||||
@@ -3,7 +3,8 @@
|
||||
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. JBIG2 generic region encoding is written from the public ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999); Annex H.2 checks the arithmetic coder byte for byte.
|
||||
|
||||
- `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`.
|
||||
|
||||
## API
|
||||
@@ -22,6 +23,11 @@ decode_tolerant(chain, data, max_output) -> Result<(Vec<u8>, bool /*partial*/),
|
||||
decode_tolerant_counted(chain, data, max_output) -> Result<(Vec<u8>, bool /*partial*/, usize /*decoded*/), (FilterError, usize /*decoded*/)>
|
||||
encode(filter, params, data) -> Result<Vec<u8>, FilterError>
|
||||
encode_flate(data) -> Vec<u8> // zlib, level 6
|
||||
encode_g4(data, columns, rows) -> Result<Vec<u8>, 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<Vec<u8>, FilterError> // CCITT fax as CCITTFaxDecode gives it; columns <= MAX_CCITT_COLUMNS
|
||||
encode_jbig2_generic(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns <= MAX_JBIG2_COLUMNS
|
||||
encode_jbig2(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode, symbol coded when that is smaller; columns <= MAX_JBIG2_COLUMNS
|
||||
```
|
||||
|
||||
- `chain` is applied first to last, as in a `/Filter` array.
|
||||
@@ -40,14 +46,23 @@ encode_flate(data) -> Vec<u8> // 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 |
|
||||
| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, or more than 2^24 rows, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made |
|
||||
|
||||
## 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.
|
||||
- `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions.
|
||||
- `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics.
|
||||
- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 65,536 distinct marks or 2^24 symbol pixels the image is coded generically, and at each cap it is still looked at; a plan codes at most 32,767 symbols, which decode, and past that the second plan still codes the repeated ones; more than 10,000 marks are placed by several text regions, numbered in turn, the dictionary retained for each but the last; 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 decoders (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2), DCT encoding, CCITT Group 3 encoding and Flate encode levels other than 6.
|
||||
|
||||
|
||||
Lossless symbol coding is also implemented: exact-copy dictionaries/text regions, bounded by the encoder; no approximate or lossy symbol substitution.
|
||||
@@ -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<Vec<u8>, 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<Vec<u8>, 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<u8>,
|
||||
row: Vec<u8>,
|
||||
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<Vec<bool>> {
|
||||
struct Rows(Vec<Vec<bool>>, Vec<bool>, 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<Vec<bool>> {
|
||||
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<u8> {
|
||||
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<u32>], eol: bool, tag: bool, fill: Fill) -> Vec<u8> {
|
||||
// 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<Vec<u32>> {
|
||||
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<u32>], columns: u32) -> Vec<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! JBIG2 encoding (ITU-T T.88 | ISO/IEC 14492), for `JBIG2Decode` (ISO 32000-2 §7.4.7), lossless:
|
||||
//! a generic region, template 0 with its nominal adaptive pixels, typical prediction on, MQ
|
||||
//! arithmetic coding ([`crate::mq`]); or the image's marks as symbols ([`symbols`]). Written from
|
||||
//! the standard's 6.2.5 (coding a generic region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8
|
||||
//! (the segments) and D.3 (the embedded organisation PDF uses).
|
||||
|
||||
mod symbols;
|
||||
|
||||
use crate::FilterError;
|
||||
use crate::mq::{Context, MqEncoder};
|
||||
|
||||
/// The widest row [`encode_jbig2_generic`] encodes. JBIG2 allows 32-bit widths; this keeps a
|
||||
/// row's buffers small and its positions in range.
|
||||
pub const MAX_JBIG2_COLUMNS: u32 = 1 << 20;
|
||||
|
||||
/// Template 0's pixels (Figure 3), as (x, y) offsets from the pixel being coded, each a bit of the
|
||||
/// context in this order: the four on the current row, the five fixed ones on the row above, the
|
||||
/// three on the row above that, then the adaptive pixels A1–A4 at their nominal places (Table 5).
|
||||
const TEMPLATE: [(i32, i32); 16] = [
|
||||
(-1, 0),
|
||||
(-2, 0),
|
||||
(-3, 0),
|
||||
(-4, 0),
|
||||
(-2, -1),
|
||||
(-1, -1),
|
||||
(0, -1),
|
||||
(1, -1),
|
||||
(2, -1),
|
||||
(-1, -2),
|
||||
(0, -2),
|
||||
(1, -2),
|
||||
(3, -1),
|
||||
(-3, -1),
|
||||
(2, -2),
|
||||
(-2, -2),
|
||||
];
|
||||
|
||||
/// The context that codes SLTP when template 0 is used (Figure 8): those pixel values, in
|
||||
/// [`TEMPLATE`]'s order. Figure 8 reads, by row from x = -4: `0 1 0 1` on the current row,
|
||||
/// `0 1 1 0 0 1 0` (x = -3 to 3) above it, `1 0 0 1 1` (x = -2 to 2) above that.
|
||||
const SLTP: [u8; 16] = [1, 0, 1, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1];
|
||||
|
||||
/// Columns of padding either side of a row, so every template pixel of a row's pixels is in it.
|
||||
const PAD: usize = 4;
|
||||
|
||||
/// `rows` rows of `columns` 1-bit PDF samples as lossless JBIG2, the smaller of two codings:
|
||||
/// [`encode_jbig2_generic`]'s, or one that stores each distinct mark of the image once, as a
|
||||
/// symbol, and places its copies (see [`symbols`]). Either way the stream is in the embedded
|
||||
/// organisation and decodes to the same bits without `/JBIG2Globals`.
|
||||
///
|
||||
/// Errors: as [`encode_jbig2_generic`].
|
||||
pub fn encode_jbig2(data: &[u8], columns: u32, rows: u32) -> Result<Vec<u8>, FilterError> {
|
||||
let generic = encode_jbig2_generic(data, columns, rows)?;
|
||||
// `encode_jbig2_generic` checked that the rows are there.
|
||||
let stride = (columns as usize).div_ceil(8);
|
||||
let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n));
|
||||
Ok(match samples.and_then(|s| symbols::encode(s, columns, rows)) {
|
||||
Some(coded) if coded.len() < generic.len() => coded,
|
||||
_ => generic,
|
||||
})
|
||||
}
|
||||
|
||||
/// `rows` rows of `columns` 1-bit PDF samples (most significant bit first, each row starting on
|
||||
/// a new byte), as a JBIG2 stream in the embedded organisation: a page information segment and an
|
||||
/// immediate lossless generic region segment, which `JBIG2Decode` decodes to the same bits
|
||||
/// without `/JBIG2Globals`.
|
||||
///
|
||||
/// A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, as a grey image's
|
||||
/// samples have it. 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_JBIG2_COLUMNS`]
|
||||
/// ([`FilterError::Unsupported`]); fewer bytes than the rows need ([`FilterError::Corrupt`]).
|
||||
pub fn encode_jbig2_generic(data: &[u8], columns: u32, rows: u32) -> Result<Vec<u8>, FilterError> {
|
||||
if columns == 0 || rows == 0 || columns > MAX_JBIG2_COLUMNS {
|
||||
return Err(FilterError::Unsupported(format!("JBIG2 encoding of {columns} × {rows} samples")));
|
||||
}
|
||||
let width = columns as usize;
|
||||
let stride = 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: "JBIG2Decode",
|
||||
detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()),
|
||||
})?;
|
||||
let mut contexts: Vec<Context> = vec![0; 1 << 16];
|
||||
let mut mq = MqEncoder::new();
|
||||
let height = samples.len() / stride;
|
||||
code_generic(&mut mq, &mut contexts, width, height, true, |y, row| {
|
||||
let packed = samples.get(y * stride..).unwrap_or_default();
|
||||
for (x, pixel) in row.iter_mut().enumerate() {
|
||||
let sample = packed.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0);
|
||||
*pixel = u8::from(!sample);
|
||||
}
|
||||
});
|
||||
let region = mq.finish();
|
||||
let mut out = Vec::with_capacity(region.len() + 64);
|
||||
segment(&mut out, 0, 48, &[], false, &page_information(columns, rows));
|
||||
segment(&mut out, 1, 39, &[], false, &generic_region(columns, rows, 0, 0, ®ion));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A page information segment's data (7.4.8): the page's size, resolution unknown, eventually
|
||||
/// lossless, default pixel 0 (white), OR combination, not striped.
|
||||
fn page_information(columns: u32, rows: u32) -> Vec<u8> {
|
||||
let mut page = Vec::with_capacity(19);
|
||||
page.extend_from_slice(&columns.to_be_bytes());
|
||||
page.extend_from_slice(&rows.to_be_bytes());
|
||||
page.extend_from_slice(&[0; 8]);
|
||||
page.push(0x01);
|
||||
page.extend_from_slice(&[0, 0]);
|
||||
page
|
||||
}
|
||||
|
||||
/// A region segment information field (7.4.1): a `width` × `height` region at (`x`, `y`) on the
|
||||
/// page, combined with it by OR.
|
||||
fn region_information(out: &mut Vec<u8>, width: u32, height: u32, x: u32, y: u32) {
|
||||
for v in [width, height, x, y] {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
out.push(0);
|
||||
}
|
||||
|
||||
/// An immediate lossless generic region segment's data (7.4.6): its region information, flags
|
||||
/// (MMR 0, template 0, TPGDON 1), the AT pixels at their nominal places, then `coded`.
|
||||
fn generic_region(width: u32, height: u32, x: u32, y: u32, coded: &[u8]) -> Vec<u8> {
|
||||
let mut data = Vec::with_capacity(26 + coded.len());
|
||||
region_information(&mut data, width, height, x, y);
|
||||
data.push(0x08);
|
||||
at_pixels(&mut data);
|
||||
data.extend_from_slice(coded);
|
||||
data
|
||||
}
|
||||
|
||||
/// Template 0's four AT pixels at their nominal places, as AT flags (7.4.6.3, 7.4.2.1.2).
|
||||
fn at_pixels(out: &mut Vec<u8>) {
|
||||
for (x, y) in &TEMPLATE[12..] {
|
||||
out.push(*x as i8 as u8);
|
||||
out.push(*y as i8 as u8);
|
||||
}
|
||||
}
|
||||
|
||||
/// A segment (7.2): its header (number, type, the segments it refers to, page 1, data length),
|
||||
/// then its data. `retained`: a later segment refers to this one. `refers_to` holds each
|
||||
/// referred-to segment's number and whether a segment after this one refers to it too (its
|
||||
/// retain bit, 7.2.4). Segment numbers here stay below 256, so each referred-to one takes a byte
|
||||
/// (7.2.5); a segment refers to at most four, so their count and retain bits take one byte too.
|
||||
fn segment(out: &mut Vec<u8>, number: u32, kind: u8, refers_to: &[(u8, bool)], retained: bool, data: &[u8]) {
|
||||
out.extend_from_slice(&number.to_be_bytes());
|
||||
out.push(kind);
|
||||
// At most four, so the count fits its three bits.
|
||||
let refers_to = refers_to.get(..refers_to.len().min(4)).unwrap_or_default();
|
||||
let kept = refers_to.iter().enumerate().fold(0u8, |bits, (k, (_, kept))| bits | u8::from(*kept) << (k + 1));
|
||||
out.push((refers_to.len() as u8) << 5 | kept | u8::from(retained));
|
||||
out.extend(refers_to.iter().map(|(n, _)| *n));
|
||||
out.push(1);
|
||||
// A segment's data never comes near 4 GiB here: a row is at most 1 MiB wide.
|
||||
out.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes());
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
|
||||
/// Codes a `width` × `height` bitmap as a generic region (6.2.5.7) with template 0 and the nominal
|
||||
/// AT pixels, its decisions in `contexts` (2^16 of them), with typical prediction when `typical`
|
||||
/// (TPGDON). `fill(y, row)` sets row `y`'s `width` pixels, 1 for black. Pixels outside the bitmap
|
||||
/// count as 0 (white), as the decoder takes them.
|
||||
fn code_generic(mq: &mut MqEncoder, contexts: &mut [Context], width: usize, height: usize, typical: bool, mut fill: impl FnMut(usize, &mut [u8])) {
|
||||
let sltp = SLTP.iter().enumerate().fold(0usize, |cx, (k, v)| cx | usize::from(*v) << k);
|
||||
// The two rows above the current one and the current one, as JBIG2 pixels (1 black), padded.
|
||||
let blank = vec![0u8; width + 2 * PAD];
|
||||
let (mut above2, mut above1) = (blank.clone(), blank.clone());
|
||||
let mut row = blank;
|
||||
let mut ltp = false;
|
||||
for y in 0..height {
|
||||
if let Some(pixels) = row.get_mut(PAD..PAD + width) {
|
||||
fill(y, pixels);
|
||||
}
|
||||
if typical {
|
||||
// Typical prediction (6.2.5.5): a row the same as the one above is coded as one decision.
|
||||
let same = row == above1;
|
||||
if let Some(cx) = contexts.get_mut(sltp) {
|
||||
mq.encode(cx, same != ltp);
|
||||
}
|
||||
ltp = same;
|
||||
}
|
||||
if !ltp {
|
||||
for x in 0..width {
|
||||
let at = |dx: i32, dy: i32| {
|
||||
let line = match dy {
|
||||
0 => &row,
|
||||
-1 => &above1,
|
||||
_ => &above2,
|
||||
};
|
||||
let i = (PAD + x).checked_add_signed(dx as isize);
|
||||
i.and_then(|i| line.get(i)).copied().unwrap_or(0)
|
||||
};
|
||||
let cx = TEMPLATE.iter().enumerate().fold(0usize, |cx, (k, (dx, dy))| cx | usize::from(at(*dx, *dy)) << k);
|
||||
if let Some(state) = contexts.get_mut(cx) {
|
||||
mq.encode(state, row.get(PAD + x) == Some(&1));
|
||||
}
|
||||
}
|
||||
}
|
||||
std::mem::swap(&mut above2, &mut above1);
|
||||
std::mem::swap(&mut above1, &mut row);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The rows an independent decoder (`hayro-jbig2`) reads from `data`, as PDF samples: true
|
||||
/// for white (a JBIG2 0).
|
||||
pub(super) fn decode(data: &[u8]) -> Vec<Vec<bool>> {
|
||||
struct Rows(Vec<Vec<bool>>, Vec<bool>, usize);
|
||||
impl hayro_jbig2::Decoder for Rows {
|
||||
fn push_pixel(&mut self, black: bool) {
|
||||
self.1.push(!black);
|
||||
}
|
||||
fn push_pixel_chunk(&mut self, black: bool, chunks: u32) {
|
||||
self.1.extend(std::iter::repeat_n(!black, 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 image = hayro_jbig2::Image::new_embedded(data, None).unwrap();
|
||||
let mut rows = Rows(Vec::new(), Vec::new(), image.width() as usize);
|
||||
image.decode(&mut rows).unwrap();
|
||||
rows.0
|
||||
}
|
||||
|
||||
/// The rows of packed samples `data`, true for a 1 bit.
|
||||
pub(super) fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec<Vec<bool>> {
|
||||
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()
|
||||
}
|
||||
|
||||
/// Rows of `columns` samples from `runs`: alternating white and black runs one longer than
|
||||
/// those values (never empty, even when shrinking makes them all 0), 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<u8> {
|
||||
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
|
||||
}
|
||||
|
||||
#[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];
|
||||
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 jbig2 = encode_jbig2_generic(&data, w, h).unwrap();
|
||||
assert_eq!(decode(&jbig2), unpack(&data, w, h));
|
||||
let g4 = crate::encode_g4(&data, w, h).unwrap();
|
||||
assert!(jbig2.len() < g4.len(), "JBIG2 {} bytes, Group 4 {}", jbig2.len(), g4.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_padding_and_typical_rows_round_trip() {
|
||||
// 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];
|
||||
assert_eq!(decode(&encode_jbig2_generic(&data, 3, 3).unwrap()), [[true, false, true], [false, true, false], [true, true, true]]);
|
||||
for (w, h, fill) in [(1, 1, 0x00), (1, 1, 0xFF), (13, 9, 0x00), (13, 9, 0xFF), (64, 40, 0x5A)] {
|
||||
let data = vec![fill; (w as usize).div_ceil(8) * h as usize];
|
||||
assert_eq!(decode(&encode_jbig2_generic(&data, w, h).unwrap()), unpack(&data, w, h), "{w} × {h} of {fill:#04x}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Packed samples from JBIG2 pixels (1 black): a 0 sample for each 1.
|
||||
pub(super) fn samples(pixels: &[Vec<u8>]) -> Vec<u8> {
|
||||
let width = pixels.first().map_or(0, Vec::len);
|
||||
let stride = width.div_ceil(8);
|
||||
let mut data = vec![0xFF; stride * pixels.len()];
|
||||
for (y, row) in pixels.iter().enumerate() {
|
||||
for (x, p) in row.iter().enumerate() {
|
||||
if *p == 1 {
|
||||
data[y * stride + x / 8] &= !(0x80 >> (x % 8));
|
||||
}
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// A row the same as the one above is typical (6.2.5.5): one decision, not a row of pixels.
|
||||
#[test]
|
||||
fn a_repeated_row_is_one_decision() {
|
||||
let row: Vec<u8> = (0..800u32).map(|x| u8::from((x * 7919 + x / 3) % 5 < 2)).collect();
|
||||
let pixels = vec![row; 1000];
|
||||
let data = samples(&pixels);
|
||||
crate::mq::DECISIONS.with(|n| n.set(0));
|
||||
let jbig2 = encode_jbig2_generic(&data, 800, 1000).unwrap();
|
||||
// The first row's 800 pixels, and one typical-row decision for each of the 1000 rows.
|
||||
assert_eq!(crate::mq::DECISIONS.with(|n| n.get()), 800 + 1000);
|
||||
assert_eq!(decode(&jbig2), unpack(&data, 800, 1000));
|
||||
}
|
||||
|
||||
/// SLTP is coded in the context Figure 8 shows, which ordinary pixels can share: an image
|
||||
/// tiled with that very neighbourhood decodes only if both coders use the same context.
|
||||
#[test]
|
||||
fn the_typical_row_flag_shares_figure_8s_context() {
|
||||
// Figure 8, around a pixel at x = 4 of an 8-column tile: two rows up x = 2..6, one row up
|
||||
// x = 1..7, the pixel's own row x = 0..3.
|
||||
let tile = [[0, 0, 1, 0, 0, 1, 1, 0], [0, 0, 1, 1, 0, 0, 1, 0], [0, 1, 0, 1, 1, 0, 0, 0]];
|
||||
let pixels: Vec<Vec<u8>> = (0..90).map(|y| (0..400).map(|x| tile[y % 3][x % 8]).collect()).collect();
|
||||
let data = samples(&pixels);
|
||||
assert_eq!(decode(&encode_jbig2_generic(&data, 400, 90).unwrap()), unpack(&data, 400, 90));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_it_cannot_encode_is_an_error() {
|
||||
assert!(matches!(encode_jbig2_generic(&[0; 8], 0, 1), Err(FilterError::Unsupported(_))));
|
||||
assert!(matches!(encode_jbig2_generic(&[0; 8], 8, 0), Err(FilterError::Unsupported(_))));
|
||||
assert!(matches!(encode_jbig2_generic(&[0; 8], MAX_JBIG2_COLUMNS + 1, 1), Err(FilterError::Unsupported(_))));
|
||||
assert!(matches!(encode_jbig2_generic(&[0; 3], 16, 2), Err(FilterError::Corrupt { .. })), "two rows of 16 need 4 bytes");
|
||||
assert!(matches!(encode_jbig2_generic(&[], 1, u32::MAX), Err(FilterError::Corrupt { .. })));
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
/// Any bitmap, of noise or of runs, decodes to its own bits through an independent decoder.
|
||||
#[test]
|
||||
fn bitmaps_round_trip(
|
||||
columns in 1u32..300,
|
||||
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 jbig2 = encode_jbig2_generic(&data, columns, rows).unwrap();
|
||||
proptest::prop_assert_eq!(decode(&jbig2), 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..5000, rows in 0u32..80) {
|
||||
let _ = encode_jbig2_generic(&data, columns, rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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`]) or lossless JBIG2 ([`encode_jbig2`]).
|
||||
//!
|
||||
//! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb
|
||||
//! defence) and never panics on malformed input.
|
||||
@@ -15,13 +16,19 @@
|
||||
|
||||
mod ascii85;
|
||||
mod asciihex;
|
||||
mod ccitt;
|
||||
mod flate;
|
||||
mod jbig2;
|
||||
mod lzw;
|
||||
mod mq;
|
||||
mod predictor;
|
||||
mod runlength;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, encode_g4};
|
||||
pub use jbig2::{MAX_JBIG2_COLUMNS, encode_jbig2, encode_jbig2_generic};
|
||||
|
||||
/// A PDF stream filter, identified by its `/Filter` name.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Filter {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! The MQ arithmetic encoder of JBIG2 (ITU-T T.88 | ISO/IEC 14492, Annex E), written from the
|
||||
//! procedures in its Figures E.2–E.12 and the probability estimates of Table E.1.
|
||||
|
||||
/// Table E.1: for each index, the LPS probability estimate Qe, the next index after an MPS and
|
||||
/// after an LPS renormalisation, and whether an LPS switches the MPS sense.
|
||||
const STATES: [(u32, u8, u8, bool); 47] = [
|
||||
(0x5601, 1, 1, true),
|
||||
(0x3401, 2, 6, false),
|
||||
(0x1801, 3, 9, false),
|
||||
(0x0AC1, 4, 12, false),
|
||||
(0x0521, 5, 29, false),
|
||||
(0x0221, 38, 33, false),
|
||||
(0x5601, 7, 6, true),
|
||||
(0x5401, 8, 14, false),
|
||||
(0x4801, 9, 14, false),
|
||||
(0x3801, 10, 14, false),
|
||||
(0x3001, 11, 17, false),
|
||||
(0x2401, 12, 18, false),
|
||||
(0x1C01, 13, 20, false),
|
||||
(0x1601, 29, 21, false),
|
||||
(0x5601, 15, 14, true),
|
||||
(0x5401, 16, 14, false),
|
||||
(0x5101, 17, 15, false),
|
||||
(0x4801, 18, 16, false),
|
||||
(0x3801, 19, 17, false),
|
||||
(0x3401, 20, 18, false),
|
||||
(0x3001, 21, 19, false),
|
||||
(0x2801, 22, 19, false),
|
||||
(0x2401, 23, 20, false),
|
||||
(0x2201, 24, 21, false),
|
||||
(0x1C01, 25, 22, false),
|
||||
(0x1801, 26, 23, false),
|
||||
(0x1601, 27, 24, false),
|
||||
(0x1401, 28, 25, false),
|
||||
(0x1201, 29, 26, false),
|
||||
(0x1101, 30, 27, false),
|
||||
(0x0AC1, 31, 28, false),
|
||||
(0x09C1, 32, 29, false),
|
||||
(0x08A1, 33, 30, false),
|
||||
(0x0521, 34, 31, false),
|
||||
(0x0441, 35, 32, false),
|
||||
(0x02A1, 36, 33, false),
|
||||
(0x0221, 37, 34, false),
|
||||
(0x0141, 38, 35, false),
|
||||
(0x0111, 39, 36, false),
|
||||
(0x0085, 40, 37, false),
|
||||
(0x0049, 41, 38, false),
|
||||
(0x0025, 42, 39, false),
|
||||
(0x0015, 43, 40, false),
|
||||
(0x0009, 44, 41, false),
|
||||
(0x0005, 45, 42, false),
|
||||
(0x0001, 45, 43, false),
|
||||
(0x5601, 46, 46, false),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Decisions coded on this thread.
|
||||
pub(crate) static DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A context's adaptive state: the index into [`STATES`] (low 7 bits) and the MPS sense (bit 7).
|
||||
/// 0, the initial value, is index 0 with MPS 0 (E.2.8).
|
||||
pub(crate) type Context = u8;
|
||||
|
||||
/// The encoder's registers (E.2.1) and the bytes written so far.
|
||||
pub(crate) struct MqEncoder {
|
||||
a: u32,
|
||||
c: u32,
|
||||
ct: u32,
|
||||
/// The output, after the byte before the first one (B at INITENC), which is 0x00 and is
|
||||
/// dropped by [`MqEncoder::finish`]: a carry can't reach it before the first byte is out.
|
||||
out: Vec<u8>,
|
||||
}
|
||||
|
||||
impl MqEncoder {
|
||||
/// INITENC (Figure E.10). The byte before the first is 0x00, not 0xFF, so CT starts at 12.
|
||||
pub(crate) fn new() -> Self {
|
||||
MqEncoder { a: 0x8000, c: 0, ct: 12, out: vec![0] }
|
||||
}
|
||||
|
||||
/// The byte B, the last one written (or the one before the first).
|
||||
fn b(&self) -> u8 {
|
||||
self.out.last().copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// ENCODE (Figures E.3–E.7): code decision `d` in context `cx`, updating its state.
|
||||
pub(crate) fn encode(&mut self, cx: &mut Context, d: bool) {
|
||||
#[cfg(test)]
|
||||
DECISIONS.with(|n| n.set(n.get() + 1));
|
||||
let (index, mps) = (usize::from(*cx & 0x7F), *cx >> 7 == 1);
|
||||
let Some(&(qe, nmps, nlps, switch)) = STATES.get(index) else { return };
|
||||
self.a -= qe;
|
||||
if d == mps {
|
||||
// CODEMPS.
|
||||
if self.a & 0x8000 != 0 {
|
||||
self.c += qe;
|
||||
return;
|
||||
}
|
||||
if self.a < qe {
|
||||
self.a = qe;
|
||||
} else {
|
||||
self.c += qe;
|
||||
}
|
||||
*cx = nmps | (*cx & 0x80);
|
||||
} else {
|
||||
// CODELPS.
|
||||
if self.a < qe {
|
||||
self.c += qe;
|
||||
} else {
|
||||
self.a = qe;
|
||||
}
|
||||
let mps = mps != switch;
|
||||
*cx = nlps | (u8::from(mps) << 7);
|
||||
}
|
||||
self.renorm();
|
||||
}
|
||||
|
||||
/// RENORME (Figure E.8).
|
||||
fn renorm(&mut self) {
|
||||
loop {
|
||||
self.a <<= 1;
|
||||
self.c <<= 1;
|
||||
self.ct -= 1;
|
||||
if self.ct == 0 {
|
||||
self.byte_out();
|
||||
}
|
||||
if self.a & 0x8000 != 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BYTEOUT (Figure E.9), with bit stuffing after 0xFF and carry into the byte before.
|
||||
fn byte_out(&mut self) {
|
||||
if self.b() == 0xFF {
|
||||
self.stuffed();
|
||||
} else if self.c < 0x800_0000 {
|
||||
self.plain();
|
||||
} else {
|
||||
let b = self.b().wrapping_add(1);
|
||||
if let Some(last) = self.out.last_mut() {
|
||||
*last = b;
|
||||
}
|
||||
if b == 0xFF {
|
||||
self.c &= 0x7FF_FFFF;
|
||||
self.stuffed();
|
||||
} else {
|
||||
self.plain();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plain(&mut self) {
|
||||
self.out.push(((self.c >> 19) & 0xFF) as u8);
|
||||
self.c &= 0x7_FFFF;
|
||||
self.ct = 8;
|
||||
}
|
||||
|
||||
fn stuffed(&mut self) {
|
||||
self.out.push(((self.c >> 20) & 0xFF) as u8);
|
||||
self.c &= 0xF_FFFF;
|
||||
self.ct = 7;
|
||||
}
|
||||
|
||||
/// FLUSH (Figures E.11 and E.12): the final bytes, then the 0xFF 0xAC marker.
|
||||
pub(crate) fn finish(mut self) -> Vec<u8> {
|
||||
let top = self.c + self.a;
|
||||
self.c |= 0xFFFF;
|
||||
if self.c >= top {
|
||||
self.c -= 0x8000;
|
||||
}
|
||||
self.c <<= self.ct;
|
||||
self.byte_out();
|
||||
self.c <<= self.ct;
|
||||
self.byte_out();
|
||||
if self.b() != 0xFF {
|
||||
self.out.push(0xFF);
|
||||
}
|
||||
self.out.push(0xAC);
|
||||
self.out.split_off(1)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Annex H.2's test sequence: 256 decisions in one context give these 30 bytes.
|
||||
#[test]
|
||||
fn the_annex_h_test_sequence_encodes_to_its_bytes() {
|
||||
let decisions = [
|
||||
0x00, 0x02, 0x00, 0x51, 0x00, 0x00, 0x00, 0xC0, 0x03, 0x52, 0x87, 0x2A, 0xAA, 0xAA, 0xAA, 0xAA, 0x82, 0xC0, 0x20, 0x00, 0xFC, 0xD7, 0x9E,
|
||||
0xF6, 0xBF, 0x7F, 0xED, 0x90, 0x4F, 0x46, 0xA3, 0xBF,
|
||||
];
|
||||
let mut mq = MqEncoder::new();
|
||||
let mut cx = 0;
|
||||
for byte in decisions {
|
||||
for bit in (0..8).rev() {
|
||||
mq.encode(&mut cx, byte >> bit & 1 == 1);
|
||||
}
|
||||
}
|
||||
let expected = [
|
||||
0x84, 0xC7, 0x3B, 0xFC, 0xE1, 0xA1, 0x43, 0x04, 0x02, 0x20, 0x00, 0x00, 0x41, 0x0D, 0xBB, 0x86, 0xF4, 0x31, 0x7F, 0xFF, 0x88, 0xFF, 0x37,
|
||||
0x47, 0x1A, 0xDB, 0x6A, 0xDF, 0xFF, 0xAC,
|
||||
];
|
||||
assert_eq!(mq.finish(), expected);
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -87,10 +87,15 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
|
||||
left alone. `calculations_changed` compares two readings of a form to say which formulas an
|
||||
edit rewrote.
|
||||
- The calculation order (`/CO`, §12.7.3): `calculation_order` lists the calculated fields in the
|
||||
order `recalculate` runs them, with the fields each formula reads (`Calculate::reads`,
|
||||
order `recalculate` runs them, each with its field object (two can share a name in a malformed
|
||||
form), with the fields each formula reads (`Calculate::reads`,
|
||||
`af::calculation_reads`; names built at run time aren't known) and what is wrong with the
|
||||
order: a field reading one that calculates later, a loop, a field `/CO` leaves out, and `/CO`
|
||||
entries that calculate nothing. `CalculationOrder::fixed` is Fix order: a stable topological
|
||||
entries that calculate nothing. Two issues are about the formula itself: it reads a field the
|
||||
form doesn't have (`MissingField`; the field of that name and a field under it count as there,
|
||||
as recalculation and `getField` find them, but not an Acrobat widget name such as `price.0`,
|
||||
which neither resolves), or its script builds names at run time (`BuiltNames`,
|
||||
`af::reads_built_names`), so its reads are incomplete. `CalculationOrder::fixed` is Fix order: a stable topological
|
||||
sort that keeps the current order wherever it can and leaves loops as they are.
|
||||
`set_calculation_order` writes `/CO` afresh. A field that gets a calculation joins the end of
|
||||
the order; one whose calculation changes keeps its place.
|
||||
@@ -108,3 +113,7 @@ Rich-text values and exact appearance metrics/layout remain incomplete. JavaScri
|
||||
execution and FDF/XFDF exchange are integrated through the engine; this crate provides
|
||||
field actions and native AF rules. `/MK /R` rotation (0, 90, 180, 270) is drawn into the
|
||||
appearance.
|
||||
|
||||
## Button export values
|
||||
|
||||
`FieldProps::export_value` edits a zero-based widget's `/Opt` value while preserving appearance names, `/AP`, `/AS`, `/V` and `/DV`. Widgets sharing an on-state remain consistent. Export-only changes do not regenerate appearances. Invalid indices, empty/Off values and ambiguous/conflicting mappings are rejected. `Field::export_values` resolves selected states for data export.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 81dc3893ad0b46189d4e80199f44f8e58fcf0d193f67e266e37f3846f756a11e # shrinks to names = [" "]
|
||||
@@ -19,10 +19,10 @@ mod script;
|
||||
|
||||
#[cfg(test)]
|
||||
use calc::{Notation, eval_notation, notation_name, notation_tokens, unique};
|
||||
pub use calc::{calculate, calculation_reads, notation_js, rename_in_calculation};
|
||||
pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation};
|
||||
pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate};
|
||||
use script::{Arg, call};
|
||||
pub use script::{calculate_js, format_js, parse_calculate, parse_format, parse_validate, validate_js};
|
||||
pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js};
|
||||
|
||||
/// The Format tab.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
|
||||
+62
-20
@@ -206,6 +206,10 @@ enum Site {
|
||||
Commas(String),
|
||||
/// A name in the simplified field notation of a `BVCALC` comment.
|
||||
Notation(String),
|
||||
/// A field read by a name built at run time: a `getField` whose argument is more than one
|
||||
/// string literal (`getField("row" + i)`), or an `AFSimple_Calculate` list that isn't
|
||||
/// written out. Which field it is isn't known.
|
||||
Built,
|
||||
}
|
||||
|
||||
impl Site {
|
||||
@@ -213,6 +217,7 @@ impl Site {
|
||||
match self {
|
||||
Site::Literal(name) | Site::Notation(name) => vec![name.as_str()],
|
||||
Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(),
|
||||
Site::Built => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,14 +234,16 @@ impl Site {
|
||||
js_str(&parts.join(","))
|
||||
})
|
||||
}
|
||||
Site::Built => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a Calculate script names fields, in order: the simplified field notation in its comment
|
||||
/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of
|
||||
/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal.
|
||||
/// A name built at run time (`getField("row" + i)`) is not a site.
|
||||
/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. A name
|
||||
/// built at run time (`getField("row" + i)`) is a [`Site::Built`], at its `getField` or
|
||||
/// `AFSimple_Calculate`.
|
||||
fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
|
||||
let mut sites = Vec::new();
|
||||
let (tokens, comments) = js_tokens(js);
|
||||
@@ -255,35 +262,64 @@ fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
|
||||
Some((range, Js::Str(text))) => Some((range.clone(), text.clone())),
|
||||
_ => None,
|
||||
};
|
||||
let built = |k: usize| tokens.get(k).map(|(range, _)| (range.clone(), Site::Built));
|
||||
for k in 0..tokens.len() {
|
||||
if ident(k, "getField")
|
||||
&& punct(k + 1, '(')
|
||||
&& punct(k + 3, ')')
|
||||
&& let Some((range, name)) = string(k + 2)
|
||||
{
|
||||
sites.push((range, Site::Literal(name)));
|
||||
if ident(k, "getField") && punct(k + 1, '(') {
|
||||
match string(k + 2) {
|
||||
Some((range, name)) if punct(k + 3, ')') => sites.push((range, Site::Literal(name))),
|
||||
_ => sites.extend(built(k)),
|
||||
}
|
||||
}
|
||||
if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) {
|
||||
if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(')) {
|
||||
continue;
|
||||
}
|
||||
if !(string(k + 2).is_some() && punct(k + 3, ',')) {
|
||||
sites.extend(built(k));
|
||||
continue;
|
||||
}
|
||||
// One string of comma-separated names, when it is the whole list (not `"row" + suffix`).
|
||||
// An argument after it leaves the list as written, as after an array: its names are read,
|
||||
// and what follows is a name built at run time.
|
||||
if let Some((range, list)) = string(k + 4) {
|
||||
sites.push((range, Site::Commas(list)));
|
||||
if punct(k + 5, ')') || punct(k + 5, ',') {
|
||||
sites.push((range, Site::Commas(list)));
|
||||
}
|
||||
if !punct(k + 5, ')') {
|
||||
sites.extend(built(k));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string.
|
||||
// Nothing else is read, so no token is read for two calls.
|
||||
let first = if punct(k + 4, '[') {
|
||||
k + 5
|
||||
// `new Array("a", "b")` or `["a", "b"]`: each item that is one string, and the list has
|
||||
// to end the call. An item that is more than a string (`"row" + i`), or anything after
|
||||
// the list (`.concat(more)`), is a name built at run time. Nothing else is read, so no
|
||||
// token is read for two calls.
|
||||
let (first, close) = if punct(k + 4, '[') {
|
||||
(k + 5, ']')
|
||||
} else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') {
|
||||
k + 7
|
||||
(k + 7, ')')
|
||||
} else {
|
||||
sites.extend(built(k));
|
||||
continue;
|
||||
};
|
||||
for (range, token) in tokens.iter().skip(first) {
|
||||
match token {
|
||||
Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))),
|
||||
Js::Punct(',') => {}
|
||||
_ => break,
|
||||
let mut j = first;
|
||||
loop {
|
||||
match tokens.get(j) {
|
||||
// An item ends at a comma or the list's end, or where a script cut short ends.
|
||||
Some((range, Js::Str(name))) if punct(j + 1, ',') || punct(j + 1, close) || tokens.get(j + 1).is_none() => {
|
||||
sites.push((range.clone(), Site::Literal(name.clone())));
|
||||
j += 1;
|
||||
}
|
||||
Some((_, Js::Punct(','))) => j += 1,
|
||||
Some((_, Js::Punct(c))) if *c == close => {
|
||||
if !punct(j + 1, ')') {
|
||||
sites.extend(built(k));
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
sites.extend(built(k));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,6 +351,12 @@ pub fn calculation_reads(js: &str) -> Vec<String> {
|
||||
unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned))
|
||||
}
|
||||
|
||||
/// Whether a Calculate script also reads fields by names it builds at run time
|
||||
/// (`getField("row" + i)`), which [`calculation_reads`] can't list.
|
||||
pub fn reads_built_names(js: &str) -> bool {
|
||||
calculation_sites(js).iter().any(|(_, site)| *site == Site::Built)
|
||||
}
|
||||
|
||||
impl Calculate {
|
||||
/// The fields it reads, each once, in the order it first names them. A name also stands for
|
||||
/// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::calc::{js_tokens, notation_js, notation_range};
|
||||
use super::calc::{js_tokens, notation_js, notation_range, notation_tokens};
|
||||
use super::format::{DATE_PRESETS, TIME_PRESETS};
|
||||
use super::{CalcOp, Calculate, Format, Validate};
|
||||
|
||||
@@ -255,7 +255,7 @@ pub fn parse_calculate(js: &str) -> Option<Calculate> {
|
||||
if js.contains("BVCALC")
|
||||
&& let Some(range) = notation_range(js, &js_tokens(js).1)
|
||||
{
|
||||
return Some(Calculate::Notation(js.get(range)?.trim().to_string()));
|
||||
return Some(Calculate::Notation(notation_text(js.get(range)?).to_string()));
|
||||
}
|
||||
let args = call(js, "AFSimple_Calculate")?;
|
||||
let op = match args.first().and_then(Arg::string)?.as_str() {
|
||||
@@ -325,6 +325,29 @@ pub fn validate_js(v: &Validate) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why [`notation_storable`] refuses a notation, in the words the Calculate tab and the tools say it.
|
||||
pub const NOTATION_NOT_STORABLE: &str = "the notation can't be stored as written: it is empty, or holds EVCALC or */, or ends in a lone \\";
|
||||
|
||||
/// Whether simplified field notation `expr` can be stored: the script keeps it in a comment,
|
||||
/// between `BVCALC` and `EVCALC`, and it has to read back from there as the same names and
|
||||
/// operators. One holding `EVCALC` or `*/` ends early, and one ending in a lone `\` escapes the
|
||||
/// space after it. An empty one is refused too: it would be stored as `event.value = ;`.
|
||||
pub fn notation_storable(expr: &str) -> bool {
|
||||
let tokens = |s: &str| notation_tokens(s).into_iter().map(|(range, _)| s.get(range).unwrap_or_default().to_owned()).collect::<Vec<_>>();
|
||||
let read = calculate_js(&Calculate::Notation(expr.to_owned())).and_then(|js| parse_calculate(&js));
|
||||
!tokens(expr).is_empty() && matches!(read, Some(Calculate::Notation(n)) if tokens(&n) == tokens(expr))
|
||||
}
|
||||
|
||||
/// `src` from its first notation token to its last: the spaces around it left out, but not an
|
||||
/// escaped space that ends the last name (`Total\ `), as `str::trim` would.
|
||||
fn notation_text(src: &str) -> &str {
|
||||
let tokens = notation_tokens(src);
|
||||
match (tokens.first(), tokens.last()) {
|
||||
(Some((first, _)), Some((last, _))) => src.get(first.start..last.end).unwrap_or_default(),
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calculate_js(c: &Calculate) -> Option<String> {
|
||||
match c {
|
||||
Calculate::None => None,
|
||||
|
||||
@@ -156,6 +156,57 @@ fn a_regex_after_a_condition_is_not_read_as_code() {
|
||||
assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\"")));
|
||||
}
|
||||
|
||||
/// A `getField` whose argument is more than one string, or an `AFSimple_Calculate` list that
|
||||
/// isn't written out, reads a field whose name isn't known until the script runs.
|
||||
#[test]
|
||||
fn names_built_at_run_time_are_noticed() {
|
||||
for js in [
|
||||
"event.value = getField(\"row\" + i).value;",
|
||||
"event.value = this.getField(name).value;",
|
||||
"AFSimple_Calculate(\"SUM\", list);",
|
||||
"AFSimple_Calculate(\"SUM\", new Array(\"a\", b));",
|
||||
"AFSimple_Calculate(\"SUM\", [\"a\", prefix + \"b\"]);",
|
||||
"AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\" + c));",
|
||||
"AFSimple_Calculate(op, new Array(\"a\"));",
|
||||
"AFSimple_Calculate(\"SUM\", \"row\" + suffix);",
|
||||
"AFSimple_Calculate(\"SUM\", [\"a\"].concat(more));",
|
||||
"AFSimple_Calculate(\"SUM\", [\"row\" + i]);",
|
||||
] {
|
||||
assert!(reads_built_names(js), "{js}");
|
||||
}
|
||||
// A string that is only the start of a name or a list isn't read, or renamed, as one.
|
||||
for (js, reads) in [
|
||||
("AFSimple_Calculate(\"SUM\", \"row\" + suffix);", vec![]),
|
||||
("AFSimple_Calculate(\"SUM\", [\"row\" + i]);", vec![]),
|
||||
("AFSimple_Calculate(\"SUM\", [\"a\", \"row\" + i]);", vec!["a"]),
|
||||
("AFSimple_Calculate(\"SUM\", [\"a\", \"b\"].concat(more));", vec!["a", "b"]),
|
||||
] {
|
||||
assert_eq!(parse_calculate(js), None, "{js} is a script");
|
||||
assert_eq!(calculation_reads(js), reads, "{js}");
|
||||
assert_eq!(rename_in_calculation(js, "row", "line"), None, "{js}");
|
||||
}
|
||||
for js in [
|
||||
"event.value = getField(\"a\").value + getField('b').value;",
|
||||
"AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\"));",
|
||||
"AFSimple_Calculate(\"SUM\", [\"a\", \"b\",]);",
|
||||
"AFSimple_Calculate(\"SUM\", \"a, b\");",
|
||||
"// getField(\"row\" + i)\nvar s = 'getField(name)'; event.value = 1;",
|
||||
"/** BVCALC a + b EVCALC **/ event.value = getField(\"a\").value + getField(\"b\").value;",
|
||||
] {
|
||||
assert!(!reads_built_names(js), "{js}");
|
||||
}
|
||||
// The names it does know are still read and renamed.
|
||||
let js = "event.value = getField(\"a\").value + getField(\"row\" + i).value + AFSimple_Calculate(\"SUM\", new Array(\"a\", b));";
|
||||
assert_eq!(calculation_reads(js), ["a"]);
|
||||
assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a\"", "\"qty\"")));
|
||||
// So are a list's, string or array, with another argument after it.
|
||||
for js in ["AFSimple_Calculate(\"SUM\", \"a, b\", x);", "AFSimple_Calculate(\"SUM\", [\"a\", \"b\"], x);"] {
|
||||
assert!(reads_built_names(js), "{js}");
|
||||
assert_eq!(calculation_reads(js), ["a", "b"], "{js}");
|
||||
assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a", "\"qty")), "{js}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in
|
||||
/// one another's field lists, don't make a rename read the rest of the script again for each.
|
||||
#[test]
|
||||
@@ -166,6 +217,21 @@ fn hostile_scripts_are_read_in_one_pass() {
|
||||
assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000))));
|
||||
}
|
||||
|
||||
/// A notation is stored only when it reads back from the script's comment as itself.
|
||||
#[test]
|
||||
fn a_notation_is_stored_only_when_it_reads_back() {
|
||||
assert!(notation_storable("Price * Qty"));
|
||||
assert!(notation_storable(" a + b "), "the spaces around it are left out when it is read");
|
||||
// A last name that ends in a space keeps it, escaped, when the notation is read back.
|
||||
let total = "Qty * Total\\ ";
|
||||
assert!(notation_storable(total));
|
||||
let read = parse_calculate(&calculate_js(&Calculate::Notation(total.into())).unwrap());
|
||||
assert_eq!(read.as_ref().map(Calculate::reads), Some(vec!["Qty".to_owned(), "Total ".to_owned()]));
|
||||
for expr in ["", " ", "a EVCALC b", "a */ x /*", "a */ b", "a\\"] {
|
||||
assert!(!notation_storable(expr), "{expr:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Notation is read through one lexer: field names with escapes, numbers and operators.
|
||||
#[test]
|
||||
fn notation_is_read_by_one_lexer() {
|
||||
@@ -213,6 +279,16 @@ proptest::proptest! {
|
||||
proptest::prop_assert_eq!(back, Some(js));
|
||||
}
|
||||
|
||||
/// A notation of any names, written into a script, reads back from it as written.
|
||||
#[test]
|
||||
fn a_notation_reads_back_as_written(names in proptest::collection::vec("\\PC{1,8}", 1..5)) {
|
||||
proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC")));
|
||||
let expr = names.iter().map(|n| notation_name(n)).collect::<Vec<_>>().join(" + ");
|
||||
let js = calculate_js(&Calculate::Notation(expr.clone())).unwrap();
|
||||
proptest::prop_assert_eq!(parse_calculate(&js), Some(Calculate::Notation(expr.clone())));
|
||||
proptest::prop_assert!(notation_storable(&expr), "{expr:?}");
|
||||
}
|
||||
|
||||
/// What a formula reads is the same whether it is read from the tab's settings or from
|
||||
/// the script they are written as.
|
||||
#[test]
|
||||
|
||||
@@ -88,6 +88,9 @@ pub struct FieldProps {
|
||||
pub max_len: Option<Option<usize>>,
|
||||
/// Choice options (display texts; export values equal them).
|
||||
pub options: Option<Vec<String>>,
|
||||
/// Change one button widget's export value without changing its appearance state.
|
||||
/// Widgets sharing that state are kept in sync. The index is zero-based.
|
||||
pub export_value: Option<(usize, String)>,
|
||||
/// Font size (0 = auto).
|
||||
pub font_size: Option<f64>,
|
||||
/// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in
|
||||
|
||||
@@ -77,6 +77,35 @@ fn write_sorted_opt(d: &mut Dict, items: &[(String, String)], old: &[usize]) ->
|
||||
(sorted.into_iter().map(|(e, _)| e.clone()).collect(), carried)
|
||||
}
|
||||
|
||||
/// Build /Opt before modifying the document, preserving appearance states and selection.
|
||||
fn button_export_options(f: &Field, index: usize, value: &str) -> Result<Vec<Object>, FormError> {
|
||||
if !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) {
|
||||
return invalid("only check boxes and radio buttons have an export value");
|
||||
}
|
||||
if value.is_empty() || value == "Off" || value.len() > 4096 {
|
||||
return invalid("export value must be non-empty, at most 4096 bytes, and not Off");
|
||||
}
|
||||
let widget = f.widgets.get(index).ok_or_else(|| FormError::Invalid("button widget index is out of range".into()))?;
|
||||
let state = widget.on_state.as_deref().ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?;
|
||||
if !f.options.is_empty() && f.options.len() != f.widgets.len() {
|
||||
return invalid("button export options do not match its widgets");
|
||||
}
|
||||
let previous = f.export_of(index);
|
||||
let mut out = Vec::with_capacity(f.widgets.len());
|
||||
for (i, w) in f.widgets.iter().enumerate() {
|
||||
let export = f.export_of(i).ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?;
|
||||
let same_state = w.on_state.as_deref() == Some(state);
|
||||
if !same_state && (export == value || w.on_state.as_deref() == Some(value)) {
|
||||
return invalid("export value conflicts with another button choice");
|
||||
}
|
||||
if same_state && Some(export) != previous {
|
||||
return invalid("widgets with the same appearance state have conflicting export values");
|
||||
}
|
||||
out.push(Object::String(PdfString::text(if same_state { value } else { export })));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Change a field's properties (General and Options tabs) and redraw it.
|
||||
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
|
||||
if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) {
|
||||
@@ -88,8 +117,18 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
patch.validate()?;
|
||||
}
|
||||
// Refused here, before anything is written, rather than stored as a different formula.
|
||||
if let Some(crate::af::Calculate::Notation(n)) = &props.calculate
|
||||
&& !crate::af::notation_storable(n)
|
||||
{
|
||||
return invalid(crate::af::NOTATION_NOT_STORABLE);
|
||||
}
|
||||
let all = fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
|
||||
let export_options = props.export_value.as_ref().map(|(index, value)| button_export_options(&f, *index, value)).transpose()?;
|
||||
if props.export_value.is_some() && props.options.is_some() {
|
||||
return invalid("give export_value or options, not both");
|
||||
}
|
||||
// Refuse a wrong type/index before any other requested property is written.
|
||||
if let Some((wi, _)) = &props.caption {
|
||||
if f.kind != FieldKind::PushButton {
|
||||
@@ -120,6 +159,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) {
|
||||
return invalid(format!("{name} is locked: unlock it first"));
|
||||
}
|
||||
if let Some(options) = export_options.as_ref()
|
||||
&& *props == (FieldProps { export_value: props.export_value.clone(), ..FieldProps::default() })
|
||||
{
|
||||
doc.update_dict(f.obj, |d| d.set(b"Opt".to_vec(), Object::Array(options.clone())))?;
|
||||
return Ok(name.to_string());
|
||||
}
|
||||
if let Some(lock) = props.locked {
|
||||
for w in &f.widgets {
|
||||
let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0);
|
||||
@@ -209,6 +254,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
// The export values in `/Opt` order once it is rewritten, for re-pointing `/I`.
|
||||
let mut new_exports: Option<Vec<String>> = None;
|
||||
let mut kept = Vec::new();
|
||||
if let Some(options) = &export_options {
|
||||
d.set(b"Opt".to_vec(), Object::Array(options.clone()));
|
||||
}
|
||||
if let Some(opts) = &props.options {
|
||||
d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect()));
|
||||
new_exports = Some(opts.clone());
|
||||
|
||||
@@ -17,18 +17,24 @@ const MAX_LINKS: usize = 1 << 20;
|
||||
pub struct CalculatedField {
|
||||
/// The field's full name.
|
||||
pub name: String,
|
||||
/// The field object. Two calculated fields can share a name in a malformed form; this tells
|
||||
/// them apart.
|
||||
pub obj: ObjRef,
|
||||
/// The fields its formula reads, each once, as the formula names them: a name also stands for
|
||||
/// the fields under it. A script's names built at run time (`getField("row" + i)`) aren't here.
|
||||
pub reads: Vec<String>,
|
||||
/// `/CO` lists it. A field it leaves out calculates after the listed ones.
|
||||
pub listed: bool,
|
||||
/// What is wrong with its place in the order.
|
||||
/// What is wrong with its formula or its place in the order.
|
||||
pub issues: Vec<CalculationIssue>,
|
||||
}
|
||||
|
||||
/// Something wrong with a calculated field's place in the calculation order.
|
||||
/// Something wrong with a calculated field's formula or its place in the calculation order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum CalculationIssue {
|
||||
/// It reads `field`, which the form doesn't have (a field deleted, renamed outside PdfCraft,
|
||||
/// or mistyped), so the formula gets no value there.
|
||||
MissingField { field: String },
|
||||
/// It reads `field`, which calculates after it, so it uses that field's previous value.
|
||||
RunsTooEarly { field: String },
|
||||
/// Its formula reads its own result: it reads `fields`, which read it back (itself, when it
|
||||
@@ -37,6 +43,9 @@ pub enum CalculationIssue {
|
||||
/// `/CO` leaves it out. PdfCraft calculates it after the listed fields, but other PDF viewers
|
||||
/// may not calculate it at all.
|
||||
NotInOrder,
|
||||
/// Its script also reads fields by names it builds at run time (`getField("row" + i)`). Those
|
||||
/// aren't in `reads`, so the order isn't checked against them.
|
||||
BuiltNames,
|
||||
}
|
||||
|
||||
/// A form's calculation order and what is wrong with it.
|
||||
@@ -152,11 +161,14 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
|
||||
.collect();
|
||||
let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads);
|
||||
let component = components(&links);
|
||||
let mut all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
|
||||
all.sort_unstable();
|
||||
let name = |k: usize| calculated.get(k).map(|(f, _)| f.name.clone()).unwrap_or_default();
|
||||
let mut out = Vec::with_capacity(calculated.len());
|
||||
for (k, ((f, listed), reads)) in calculated.iter().zip(reads).enumerate() {
|
||||
let own = component.get(k).copied();
|
||||
let mut issues = Vec::new();
|
||||
let mut issues: Vec<CalculationIssue> =
|
||||
reads.iter().filter(|name| !found(&all, name)).map(|name| CalculationIssue::MissingField { field: name.clone() }).collect();
|
||||
let read = links.get(k).map(Vec::as_slice).unwrap_or_default();
|
||||
for &j in read {
|
||||
if j > k && component.get(j).copied() != own {
|
||||
@@ -172,12 +184,25 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
|
||||
if !listed {
|
||||
issues.push(CalculationIssue::NotInOrder);
|
||||
}
|
||||
out.push(CalculatedField { name: f.name.clone(), reads, listed: *listed, issues });
|
||||
if f.actions.scripts.calculate.as_deref().is_some_and(crate::af::reads_built_names) {
|
||||
issues.push(CalculationIssue::BuiltNames);
|
||||
}
|
||||
out.push(CalculatedField { name: f.name.clone(), obj: f.obj, reads, listed: *listed, issues });
|
||||
}
|
||||
let fixed = fixed_order(&links, &component);
|
||||
CalculationOrder { fields: out, extra, fixed }
|
||||
}
|
||||
|
||||
/// Whether a formula's read `name` finds a field in `names` (sorted): the field of that name or a
|
||||
/// field under it (`row` for `row.1`), as [`links`], [`crate::recalculate`] and the scripts'
|
||||
/// `getField` look names up. Acrobat's widget names (`price.0`) aren't fields to any of them, so
|
||||
/// a read of one gets no value and is missing too.
|
||||
fn found(names: &[&str], name: &str) -> bool {
|
||||
let prefix = format!("{name}.");
|
||||
let under = names.partition_point(|n| *n < prefix.as_str());
|
||||
names.binary_search(&name).is_ok() || names.get(under).is_some_and(|n| n.starts_with(&prefix))
|
||||
}
|
||||
|
||||
/// For each calculated field (by its place in `names`), the places of the calculated fields it
|
||||
/// reads, sorted. A read name finds the field of that name and the fields under it, as
|
||||
/// [`crate::recalculate`] looks values up.
|
||||
@@ -383,6 +408,19 @@ mod tests {
|
||||
assert_eq!(links(names.into_iter(), &reads)[5], [0, 1, 2], "row, row.1 and row.10; not rows or row-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_name_finds_a_field_or_a_group() {
|
||||
let mut names = vec!["price", "row.1", "row.2", "rows", "total"];
|
||||
names.sort_unstable();
|
||||
for name in ["price", "row", "row.1", "rows"] {
|
||||
assert!(found(&names, name), "{name}");
|
||||
}
|
||||
// A widget name finds nothing: recalculation and getField don't resolve it either.
|
||||
for name in ["pric", "ro", "row.3", "price.", "price.0", "price.12", "total.0.1", ""] {
|
||||
assert!(!found(&names, name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two thousand formulas each reading a group of two thousand fields would be four million
|
||||
/// reads; the checks stop at their cap.
|
||||
#[test]
|
||||
|
||||
@@ -220,6 +220,15 @@ impl Field {
|
||||
self.widgets.iter().position(|w| w.on_state.as_deref() == Some(state)).and_then(|i| self.export_of(i)).unwrap_or(state)
|
||||
}
|
||||
|
||||
/// Values for data export, resolving button appearance names through /Opt.
|
||||
pub fn export_values(&self) -> Vec<String> {
|
||||
if matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) {
|
||||
self.value.iter().map(|v| self.export_for_state(v).to_string()).collect()
|
||||
} else {
|
||||
self.value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The value as one string: text, the state name, or the selected display texts.
|
||||
pub fn display_value(&self) -> String {
|
||||
match self.kind {
|
||||
|
||||
@@ -1711,6 +1711,73 @@ fn the_calculation_order_is_checked_fixed_and_kept() {
|
||||
assert_eq!(order.fixed(), ["sub", "total", "tax", "grand"], "only tax moves, to just after total");
|
||||
}
|
||||
|
||||
/// `set_props` refuses a notation it couldn't store as written before it writes anything: the
|
||||
/// tooltip given with it, which is written before the calculation, isn't set either.
|
||||
#[test]
|
||||
fn a_notation_that_cannot_be_stored_changes_nothing() {
|
||||
use crate::af::Calculate;
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(),
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (total) /Rect [50 700 140 711] /P 3 0 R >>".into(),
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
for n in ["", "a EVCALC b", "a */ b", "a\\"] {
|
||||
let props = FieldProps { tooltip: Some("Total".into()), calculate: Some(Calculate::Notation(n.into())), ..Default::default() };
|
||||
assert!(set_props(&mut doc, "total", &props).is_err(), "{n:?}");
|
||||
let all = fields(&doc);
|
||||
let f = field(&all, "total");
|
||||
assert_eq!((f.tooltip.as_deref(), &f.actions.calculate), (None, &Calculate::None), "{n:?} changed nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// A formula that reads a field the form doesn't have is flagged, whichever kind of formula it
|
||||
/// is: a field deleted from under a sum, a typo in notation, a script's `getField`. So is a
|
||||
/// script that builds the names it reads. Neither is an order Fix order can fix.
|
||||
#[test]
|
||||
fn missing_fields_and_built_names_are_flagged() {
|
||||
use crate::af::{CalcOp, Calculate};
|
||||
use crate::calc_order::CalculationIssue::{BuiltNames, MissingField};
|
||||
let mut doc = one_page();
|
||||
let text = NewField::Text { multiline: false };
|
||||
for (i, name) in ["qty", "gone", "sum", "notation", "script", "built"].into_iter().enumerate() {
|
||||
let y = 700.0 - 40.0 * i as f64;
|
||||
add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap();
|
||||
}
|
||||
let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() };
|
||||
set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "gone".into()] })).unwrap();
|
||||
set_props(&mut doc, "notation", &calculate(Calculate::Notation("qty * nope".into()))).unwrap();
|
||||
// qty has one widget, but a widget name is no field: recalculation and getField give qty.0
|
||||
// nothing, so it is as missing as qty.1.
|
||||
let script = "event.value = getField(\"qty\").value + getField(\"ghost\").value + getField(\"qty.0\").value + getField(\"qty.1\").value;";
|
||||
set_field_script(&mut doc, "script", "calculate", Some(script)).unwrap();
|
||||
set_field_script(&mut doc, "built", "calculate", Some("event.value = getField(\"qty\").value + getField(\"row\" + 1).value;")).unwrap();
|
||||
let issues = |doc: &Document| {
|
||||
let order = calculation_order(doc, &fields(doc));
|
||||
assert!(!order.fixable(), "nothing Fix order can fix");
|
||||
order.fields.into_iter().map(|f| (f.name, f.issues)).collect::<Vec<_>>()
|
||||
};
|
||||
let expected = |sum: Vec<_>| {
|
||||
vec![
|
||||
("sum".to_owned(), sum),
|
||||
("notation".to_owned(), vec![MissingField { field: "nope".into() }]),
|
||||
(
|
||||
"script".to_owned(),
|
||||
vec![MissingField { field: "ghost".into() }, MissingField { field: "qty.0".into() }, MissingField { field: "qty.1".into() }],
|
||||
),
|
||||
("built".to_owned(), vec![BuiltNames]),
|
||||
]
|
||||
};
|
||||
assert_eq!(issues(&doc), expected(vec![]));
|
||||
delete_field(&mut doc, "gone").unwrap();
|
||||
assert_eq!(issues(&doc), expected(vec![MissingField { field: "gone".into() }]), "a deleted field leaves the sum reading nothing");
|
||||
assert_eq!(issues(&reopen(&doc)), expected(vec![MissingField { field: "gone".into() }]));
|
||||
let order = calculation_order(&doc, &fields(&doc));
|
||||
assert_eq!(order.fields[3].reads, ["qty"], "the names it does know are still read");
|
||||
}
|
||||
|
||||
/// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an
|
||||
/// entry repeated, a reference to nothing and a number. The order reads around them, and setting
|
||||
/// the order writes `/CO` afresh.
|
||||
@@ -1775,6 +1842,12 @@ fn two_fields_with_one_name_keep_their_places_in_the_order() {
|
||||
};
|
||||
let row = |name: &str, reads: &str, ok: bool| (name.to_owned(), vec![reads.to_owned()], ok);
|
||||
assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)]);
|
||||
// Each row is its own field object, so a caller can find the right formula for each dup.
|
||||
let all = fields(&doc);
|
||||
for c in calculation_order(&doc, &all).fields {
|
||||
let f = all.iter().find(|f| f.obj == c.obj).unwrap();
|
||||
assert_eq!((&f.name, f.actions.calculate.reads()), (&c.name, c.reads.clone()));
|
||||
}
|
||||
let current: Vec<String> = ["dup", "src", "dup"].map(String::from).into();
|
||||
set_calculation_order(&mut doc, ¤t).unwrap();
|
||||
assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)], "the current order sets back unchanged");
|
||||
@@ -5356,3 +5429,59 @@ endstream".into(),
|
||||
assert!(f.value.iter().all(String::is_empty), "{kind:?}: reset leaves it off, not the parent's Yes: {:?}", f.value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_button_export_values_preserves_selection_and_appearance() {
|
||||
let mut doc = fixture();
|
||||
set_value(&mut doc, "size", &FieldValue::Radio(Some("S".into()))).unwrap();
|
||||
let before = field(&fields(&doc), "size").clone();
|
||||
let appearances: Vec<_> = before.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect();
|
||||
set_props(&mut doc, "size", &FieldProps { export_value: Some((0, "Small å".into())), ..Default::default() }).unwrap();
|
||||
let after = field(&fields(&doc), "size").clone();
|
||||
assert_eq!(after.value, before.value);
|
||||
assert_eq!(after.default, before.default);
|
||||
assert_eq!(after.widgets, before.widgets);
|
||||
assert_eq!(after.export_of(0), Some("Small å"));
|
||||
assert_eq!(after.export_of(1), before.export_of(1));
|
||||
assert_eq!(after.export_values(), vec!["Small å"]);
|
||||
assert_eq!(appearances, after.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect::<Vec<_>>());
|
||||
let mut saved = reopen(&doc);
|
||||
assert_eq!(field(&fields(&saved), "size").export_of(0), Some("Small å"));
|
||||
set_value(&mut saved, "size", &FieldValue::Radio(Some("Small å".into()))).unwrap();
|
||||
reset(&mut saved, None).unwrap();
|
||||
assert_eq!(field(&fields(&saved), "size").value, before.default);
|
||||
|
||||
set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap();
|
||||
set_props(&mut doc, "agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap();
|
||||
let check = field(&fields(&doc), "agree").clone();
|
||||
assert_eq!(check.value, vec!["Yes"]);
|
||||
assert_eq!(check.export_values(), vec!["Accepted"]);
|
||||
assert_eq!(field(&fields(&reopen(&doc)), "agree").export_of(0), Some("Accepted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_button_export_edits_leave_document_unchanged() {
|
||||
let mut doc = fixture();
|
||||
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
for (field_name, index, value) in [("size", 99, "new"), ("size", 0, ""), ("size", 0, "Off"), ("size", 0, "L"), ("name", 0, "new")] {
|
||||
assert!(set_props(&mut doc, field_name, &FieldProps { export_value: Some((index, value.into())), ..Default::default() }).is_err());
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_button_export_edits_leave_the_document_unchanged() {
|
||||
for name in ["agree", "size"] {
|
||||
let mut doc = fixture();
|
||||
set_props(&mut doc, name, &FieldProps { locked: Some(true), ..Default::default() }).unwrap();
|
||||
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let error = set_props(
|
||||
&mut doc,
|
||||
name,
|
||||
&FieldProps { export_value: Some((0, "Changed".into())), tooltip: Some("Must not change".into()), ..Default::default() },
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("locked"));
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,4 @@ zune-jpeg = "0.5"
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-render = { workspace = true }
|
||||
proptest.workspace = true
|
||||
@@ -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,64 @@ 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: 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.
|
||||
|
||||
- Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when
|
||||
smaller. No lossy symbol substitution is used. Export reads embedded images and bounded
|
||||
shared globals.
|
||||
- **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
|
||||
|
||||
+784
-23
@@ -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<Dict> {
|
||||
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<ObjRef>,
|
||||
/// 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<ObjRef>,
|
||||
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<ObjRef, f64>,
|
||||
/// 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<u32>,
|
||||
/// 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<u8>),
|
||||
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<u32>,
|
||||
}
|
||||
|
||||
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<ObjRef, f64> {
|
||||
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<ObjRef, f64>, HashSet<u32>, Read) {
|
||||
let mut walk = Walk {
|
||||
doc,
|
||||
path: Vec::new(),
|
||||
@@ -156,12 +264,15 @@ pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef
|
||||
budget: DecodeBudget::page(),
|
||||
complete: true,
|
||||
out: HashMap::new(),
|
||||
defaults: HashSet::new(),
|
||||
read: Read::default(),
|
||||
};
|
||||
for p in pages {
|
||||
let Some(dict) = doc.get(*p).as_dict().cloned() else { continue };
|
||||
let page = pdfcraft_model::Page { obj: *p, dict };
|
||||
walk.resources_above(*p);
|
||||
let Some(contents) = page.dict.get(b"Contents") else { continue };
|
||||
let res = page_resources(doc, *p).unwrap_or_default();
|
||||
let (res, holder) = held_page_resources(doc, *p).unwrap_or_else(|| (Dict::default(), *p));
|
||||
walk.budget = DecodeBudget::page();
|
||||
let Some(data) = content_bytes(doc, contents, &mut walk.budget) else {
|
||||
walk.complete = false;
|
||||
@@ -169,12 +280,103 @@ pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef
|
||||
};
|
||||
// Content coordinates are in page units; image resolution is in physical inches.
|
||||
let unit = page.user_unit(doc);
|
||||
walk.content(&data, &res, Matrix([unit, 0.0, 0.0, unit, 0.0, 0.0]));
|
||||
walk.content(&data, &res, holder, Matrix([unit, 0.0, 0.0, unit, 0.0, 0.0]), false);
|
||||
}
|
||||
if !walk.complete {
|
||||
walk.out.values_mut().for_each(|v| *v = f64::INFINITY);
|
||||
walk.defaults.extend(walk.out.keys().map(|r| r.num));
|
||||
}
|
||||
walk.out
|
||||
(walk.out, walk.defaults, walk.read)
|
||||
}
|
||||
|
||||
/// Whether image `r` is one [`one_bit`] may make grey: eight-bit `DeviceRGB`, not in an image codec.
|
||||
fn device_rgb(doc: &Document, r: ObjRef) -> 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<u32>, read: &Read) -> HashSet<u32> {
|
||||
if images.is_empty() {
|
||||
return HashSet::new();
|
||||
}
|
||||
let targets: HashSet<u32> = read.entries.iter().map(|(target, ..)| *target).collect();
|
||||
// References to each object the walk followed, by the object holding them.
|
||||
let mut held: HashMap<u32, HashMap<u32, usize>> = HashMap::new();
|
||||
let mut queue: Vec<u32> = 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<u32, Vec<u32>> = HashMap::new();
|
||||
for (target, holders) in &held {
|
||||
for h in holders.keys() {
|
||||
holds.entry(*h).or_default().push(*target);
|
||||
}
|
||||
}
|
||||
let mut covered: HashSet<u32> = held.keys().copied().collect();
|
||||
let passes = |t: u32, covered: &HashSet<u32>| {
|
||||
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<u32> = 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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<usize> {
|
||||
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,354 @@ 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<bool>;
|
||||
|
||||
/// 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<u8>, u32, u32, Option<Dict>)> {
|
||||
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::<Option<Vec<f64>>>()?),
|
||||
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<Vec<u8>> {
|
||||
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)
|
||||
}
|
||||
|
||||
/// Resolve polarity only for explicitly understood samples. Indexed palettes and custom
|
||||
/// colour spaces stay at their original resolution rather than guessing which entry is dark.
|
||||
fn dark_is_one(doc: &Document, d: &Dict) -> Option<bool> {
|
||||
let number = |v: Option<&Object>| v.and_then(|v| doc.resolve(v).as_f64()).filter(|v| v.is_finite());
|
||||
let (d0, d1) = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() {
|
||||
Some(Object::Array(a)) if a.len() == 2 => number(a.first()).zip(number(a.get(1)))?,
|
||||
None => (0.0, 1.0),
|
||||
_ => return None,
|
||||
};
|
||||
if d0 == d1 {
|
||||
return None;
|
||||
}
|
||||
if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) {
|
||||
return Some(d1 < d0);
|
||||
}
|
||||
match d.get(b"ColorSpace").map(|cs| doc.resolve(cs)).as_deref() {
|
||||
Some(Object::Name(n)) if matches!(n.as_slice(), b"DeviceGray" | b"G") => Some(d1 < d0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `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.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn downsample_bits(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_is_one: bool) -> Option<(Vec<u8>, u64)> {
|
||||
downsample_bits_checked(data, w, h, nw, nh, dark_is_one).map(|(out, mixed, _)| (out, mixed))
|
||||
}
|
||||
|
||||
fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_is_one: bool) -> Option<(Vec<u8>, u64, bool)> {
|
||||
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;
|
||||
let mut previous_mixed = vec![false; usize::try_from(nw).ok()?];
|
||||
let mut local_tone = false;
|
||||
for j in 0..nh {
|
||||
let mut left_mixed = false;
|
||||
let mut above_left_mixed = false;
|
||||
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;
|
||||
let is_mixed = covered > 0 && zeros > 0;
|
||||
mixed += u64::from(is_mixed);
|
||||
let column = usize::try_from(i).ok()?;
|
||||
let above = *previous_mixed.get(column)?;
|
||||
local_tone |= is_mixed && left_mixed && above && above_left_mixed;
|
||||
*previous_mixed.get_mut(column)? = is_mixed;
|
||||
left_mixed = is_mixed;
|
||||
above_left_mixed = above;
|
||||
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, local_tone))
|
||||
}
|
||||
|
||||
/// 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_checked`]) 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 && settings.compression == MonoCompression::CcittG4)) && !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 polarity = if defaults { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) };
|
||||
let averaged = if (nw, nh) != (w, h) && polarity.is_some() {
|
||||
// The dark sample of the image as it ends up: packed RGB is grey, with grey's decode pair.
|
||||
let (out, mixed, local_tone) = downsample_bits_checked(&data, w, h, nw, nh, polarity?)?;
|
||||
// A halftone or dither: most new pixels cover both samples. Text covers both only along
|
||||
// its edges.
|
||||
// A dense 2x2 patch of mixed output samples can encode a local photo even when
|
||||
// white margins dominate the page. Conservatively retain the original image.
|
||||
(!local_tone && 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 && settings.compression == MonoCompression::CcittG4)) && !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 jbig = if settings.compression == MonoCompression::Jbig2 {
|
||||
pdfcraft_filters::encode_jbig2(&data, nw, nh).ok().map(|coded| {
|
||||
let mut dict = dict.clone();
|
||||
dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
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, jbig, 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 +1100,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<u32> = 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<ObjRef> = ppi
|
||||
.keys()
|
||||
@@ -551,6 +1117,7 @@ pub(crate) fn run(
|
||||
let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect();
|
||||
refs.sort_by_key(|(r, _)| r.num);
|
||||
let total = refs.len();
|
||||
let mut jbig2 = false;
|
||||
for (done, (r, ppi)) in refs.into_iter().enumerate() {
|
||||
if !progress(Stage::Images { done, total }) {
|
||||
return Err(OptimizeError::Cancelled);
|
||||
@@ -558,9 +1125,53 @@ 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;
|
||||
}
|
||||
jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode");
|
||||
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) {
|
||||
jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode");
|
||||
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();
|
||||
@@ -577,6 +1188,23 @@ pub(crate) fn run(
|
||||
if !progress(Stage::Images { done: total, total }) {
|
||||
return Err(OptimizeError::Cancelled);
|
||||
}
|
||||
if jbig2 {
|
||||
at_least_pdf_1_4(doc)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// JBIG2Decode is PDF 1.4: a document whose header says less, and whose catalog doesn't say 1.4
|
||||
/// or more already (directly or by reference), gets `/Version /1.4` in its catalog (ISO 32000-2
|
||||
/// §7.7.2). A full save with object streams writes a 1.5 header anyway.
|
||||
fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> {
|
||||
let below = |v: &[u8]| v.len() < 3 || v < &b"1.4"[..];
|
||||
let Some(root) = doc.root() else { return Ok(()) };
|
||||
let version = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned());
|
||||
let catalog = version.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec));
|
||||
if below(doc.version().as_bytes()) && catalog.as_deref().is_none_or(below) {
|
||||
doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.4")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -631,3 +1259,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<u8> {
|
||||
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()], Some(false)),
|
||||
(vec![grey, inverted.clone()], Some(true)),
|
||||
(vec![mask.clone()], Some(false)),
|
||||
(vec![mask, inverted.clone()], Some(true)),
|
||||
(vec![separation.clone()], None),
|
||||
(vec![separation, inverted.clone()], None),
|
||||
(vec![indexed.clone()], None),
|
||||
(vec![indexed, inverted], None),
|
||||
(vec![("ColorSpace", Object::name("DeviceGray")), ("Decode", Object::Array(vec![Object::name("x"), 0.into()]))], None),
|
||||
] {
|
||||
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<u8> = 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 lossless JBIG2, 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,37 @@ 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,
|
||||
/// JBIG2, lossless (a generic region, or symbols when the image's marks repeat), when smaller
|
||||
/// than the image as it is. JBIG2Decode is PDF 1.4: an older document gets `/Version /1.4` in
|
||||
/// its catalog.
|
||||
Jbig2,
|
||||
/// 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 +182,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 +218,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 +577,7 @@ fn strip_ascii_wrapper(s: &Stream) -> Option<Stream> {
|
||||
|
||||
/// 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<usize> {
|
||||
pub(crate) fn stored_len(s: &Stream) -> Option<usize> {
|
||||
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();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<Vec<u8>, 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)
|
||||
}
|
||||
|
||||
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user